语言与语言的高并发底层设计

node.js、Scala、Clojure的高并发

 

程序始终是需要由 CPU 执行的,所以真正并行的计算程序数量不会超过 CPU 的核心数。那么,宣称的“高并发”是什么?
所谓“高并发”通常是在讨论网络 I/O 的情况下出现的概念,通常而言,高并发意味着可以同时处理更多的网络请求数。提高网络请求并发量的方法是什么?
  • 最基础的是使用 epoll / kqueue 代替 select / poll,在注册、获取事件的部分从 O(n) 的复杂度提高到 O(1) [1] 。Node.js 应该是基于 <del>libev</del>libuv [2],Scala Akka 在 1.x 应该是基于 Netty [3],2.x 切换到 库 [3][4],都是平台相关的高性能 I/O 事件处理框架。
  • 在底层获得网络数据之后,如何进行处理是第二个步骤。
    • 最古典的做法是为每个 socket 启动单独的进程 / 线程并进行阻塞通信:好处是逻辑连贯,每个进程执行的就是整个协议对应的处理流程;坏处是在高并发环境下 OS 调度负载太高,浪费了 CPU 时间。
    • Node.js 的解决方法是所有可能阻塞的地方均使用回调函数指明之后的操作,所以 Node.js 可以尽可能快的切换到其它会话,并在可以继续执行回调之后回到当前会话;坏处是:
      • Node.js 只有一个线程执行处理逻辑(可以通过 Node Cluster Module 扩展)
      • 回调函数构造的逻辑流程难以理解
    • Scala / Akka 的方法是构建非常轻量级的 Actor 进行处理逻辑的封装。Scala 系统负责在有限的操作系统线程之上调度大量的 Actor 对象;因为 Actor “轻”的特性,调度快速,不会造成 OS 的负担;同时 Actor 又可以实现逻辑的连贯性,比回调方式写成的代码容易理解。
Clojure 本身没有对网络 I/O 进行优化(更新:Clojure 1.5 出现了类似 Go 的 core.async [5]),与上面的话题根本聊不到一起去。它可以使用标准的 JVM 技术,并且最常见的 Clojure 应用部署还是基于 Jetty 或 Tomcat。Tomcat 默认的是 one-thread-per-client,后来新加了 nio connector 以提高 I/O 性能。另外有 http-kit 这样的非阻塞 HTTP 框架可选。
Clojure 并不说适合高并发网络服务,但 Clojure 相比 Java 更适合并行程序的开发。它的特性在于
  1. 它本身是一门鼓励使用 immutable 数据的函数式语言,因此相比基于对象状态的 Java 程序,Clojure 的每个函数都更容易放到不同线程中执行
  2. Clojure 也提供了包括 pmap / future 等方法进行线程之间的调度和任务分派。<del>但因为 Clojure 的并发是紧紧绑定在 Java 线程之上的,比 Akka 的 Actor 要相对重量得多。</del> Clojure 提供了不同的调度方法:agent send 直接绑定 Java 线程因此相对重量级,而 pmap [6] / agent send-off 或 core.async [6] 等都使用线程池,通常为 #cores + 2 个线程。
  3. 相比 Node.js / Go, Clojure 的劣势在于它默认没有提供 async I/O 基础,而使用 Java 的 I/O 的情况下非常容易被阻塞整个线程。需要同时正确使用 Clojure 提供的调度工具及合适的 async I/O 库才能达到 Node.js / Scala () 库整合提供的高吞吐能力。
相比 Go, Node.js 这些为高并发 I/O 而设计的语言,Clojure 提供了相对更为正交的功能集合(STM,并发支持,独立的异步 I/O 库),虽然要达到同样的目的可能略微复杂,但是灵活性也更高吧。
 

 Select、Poll与Epoll简介与比较:

Select

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

1 单个进程可监视的fd数量被限制

2 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

3 对socket进行扫描时是线性扫描

Poll

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

Epoll

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。

在前面说到的复制问题上,epoll使用mmap减少复制开销。

还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知

注:水平触发(level-triggered)——只要满足条件,就触发一个事件(只要有数据没有被获取,内核就不断通知你);边缘触发(edge-triggered)——每当状态变化时,触发一个事件。

Select、Poll与Epoll区别

 

Select

Poll

Epoll

支持最大连接数

1024(x86) or 2048(x64)

无上限

无上限

IO效率

每次调用进行线性遍历,时间复杂度为O(N)

每次调用进行线性遍历,时间复杂度为O(N)

使用“事件”通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到rdllist里面,这样epoll_wait返回的时候我们就拿到了就绪的fd。时间发复杂度O(1)

fd拷贝

每次select都拷贝

每次poll都拷贝

调用epoll_ctl时拷贝进内核并由内核保存,之后每次epoll_wait不拷贝

 

使用

当同事需要保持很多的长连接,而且连接的开关很频繁时,就能够发挥epoll最大的优势了。这里与服务器模型其实已经有些交集了。

同时需要保持很多的长连接,而且连接的开关很频繁,最高效的模型是非阻塞、异步IO模型。而且不要用select/poll,这两个API的有着O(N)的时间复杂度。在Linux用epoll,BSD用kqueue,Windows用IOCP,或者用libevent封装的统一接口(对于不同平台libevent实现时采用各个平台特有的API),这些平台特有的API时间复杂度为O(1)。 然而在非阻塞,异步I/O模型下的编程是非常痛苦的。由于I/O操作不再阻塞,报文的解析需要小心翼翼,并且需要亲自管理维护每个链接的状态。并且为了充分利用CPU,还应结合线程池,避免在轮询线程中处理业务逻辑。

但这种模型的效率是极高的。以知名的http服务器nginx为例,可以轻松应付上千万的空连接+少量活动链接,每个连接连接仅需要几K的内核缓冲区,想要应付更多的空连接,只需简单的增加内存(数据来源为淘宝一位工程师的一次技术讲座,并未实测)。这使得DDoS攻击者的成本大大增加,这种模型攻击者只能将服务器的带宽全部占用,才能达到目的,而两方的投入是不成比例的。

注:长连接——连接后始终不断开,然后进行报文发送和接受;短链接——每一次通讯都建立连接,通讯完成即断开连接,下次通讯再建立连接。

 
fd是什么:
这个FD就是File Discriptor 中文翻译为文件描述符
Socket起源于unix,Unix中把所有的资源都看作是文件,包括设备,比如网卡、打印机等等,所以,针对Socket通信,我们在使用网卡,网卡又处理N多链接,每个链接都需要一个对应的描述,也就是惟一的ID,即对应的文件描述符。简单点说也就是 int fd = socket(AF_INET,SOCK_STREAM, 0); 函数socket()返回的就是这个描述符。在传输中我们都要使用这个惟一的ID来确定要往哪个链接上传输数据。
 
部分来源于知乎:
链接:http://www.zhihu.com/question/21550601/answer/21180498
原文地址:https://www.cnblogs.com/gauze/p/5484203.html