常见的并发网络服务器设计方案

2019-07-13  本文已影响0人  _给我一支烟_

1. 并发网络服务器设计方案

下表是陈硕总结的 12 种常见方案。其中“多连接互通”指的是如果开发 chat 服务,多个客户连接之间是否能方便的交换数据。“顺序性” 指客户端顺序发送多个请求,那么服务器计算得到的多个响应是否按相同的顺序发还给客户端。


常见的并发网络服务器设计方案.png

其中比较实用的有5种方案


实用的并发网络服务器设计方案.png

表中 N 表示并发连接数目,C1 和 C2 是与连接数无关、与 CPU 数目有关的常数。

2. Reactor模式

Reactor模式(反应器模式):是一个使用了同步非阻塞的 I/O 多路复用机制的模式,即 “non-blocking IO + IO multiplexing” 这种模型(本质是 event-driven 事件驱动一种实现方式)。

使用 IO multiplexing,也就是 select / poll / epoll 这一系列的 “多路选择器”,让一个 thread-of-control 能处理多个连接。“IO 复用” 其实复用的不是 IO 连接,而是复用线程。使用 IO multiplexing 几乎肯定要配合 non-blocking IO,而使用 non-blocking IO 肯定要使用应用层 buffer。原因如下

为什么 non-blocking 几乎总是和 IO-multiplexing 一起使用?

为什么 non-blocking 网络编程中应用层 buffer 是必须的?

Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系统调用上,这样可以最大限度的复用 thread-of-control,让一个线程能服务于多个 socket 连接。 IO 线程只能阻塞在 IO-multiplexing 函数上,如 select() / poll() / epoll_wait()。这样一来应用层的缓冲区是必须的,每个 TCP socket 都要有 input buffer 和 output buffer。

在 “non-blocking IO + IO multiplexing” 这种模型下,程序的基本结构是一个事件循环(event loop),伪代码如下

while (!done) 
{
    int timeout_ms = max(1000, getNextTimedCallback());
    int retval = ::poll(fds, nfds, timeout_ms);
    if (retval < 0) {
        //错误处理
    } else {
        //处理到期的 timers
        if (retval > 0) {
            //处理 IO 事件
        }
    }
}

当然, select / poll 有很多不足,Linux 下可替换为 epoll,它们之间的区别见《关于 select、poll、epoll 的区别》,其他操作系统也有对应的高性能替代品。

Douglas C. Schmidt 为我们总结的 Recator 模式:

Recator 结构

Recator 结构.png

Recator模式的角色构成

Reactor 模式处理流程

Reactor 模式处理流程.png

3. 对五种实用网络服务器方案的解读

方案2:thread-per-connection

经典的每个连接对应一个线程的同步阻塞 I/O 模式。
这是传统的 Java 网络编程方案 thread-per-connection, 在 Java1.4 引入 NIO 之前, Java 网络服务器程序多采用这种方案。这种方案的伸缩性受到线程数的限制,一两百个还行,几千个的话对操作系统的 scheduler 恐怕是个不小的负担。


thread-per-connection.png

流程:
① 服务端的 Server 是一个线程,线程中执行一个死循环来阻塞的监听客户端的连接请求和通信。
② 当客户端向服务端发送一个连接请求后,服务端的 Server 会接受客户端的请求,accept() 从阻塞中返回,得到一个与客户端连接 socket。
③ 创建一个线程并启动该线程,构建一个 handler,将socket传入该 handler。在线程中执行 handler,这样与客户端的所有的通信以及数据处理都在该线程中执行。当该客户端和服务器端完成通信关闭连接后,线程就会被销毁。
④ Server 继续执行 accept() 操作等待新的连接请求。

优点:使用简单,容易编程。
缺点:并发性不高,伸缩性受到线程数的限制。
适用场景:如果只有少量的连接使用非常高的带宽,一次发送大量的数据,也许该方案比较适合。

方案5:单线程 reactor

单线程 Reactor 模式

单线程 reactor.png
流程:
① 服务端的 Reactor 是一个线程对象,该线程会启动事件循环(EventLoop)。注册一个 Acceptor 事件处理器到 Reactor 中, Acceptor 事件处理器所关注的事件是 ACCEPT 事件,这样 Reactor 会监听客户端向服务器发起的连接请求事件(ACCEPT 事件)。
② 客户端向服务器发起一个连接请求后,Reactor 监听到 ACCEPT 事件的发生并将该 ACCEPT 事件派发到相应的 Acceptor 处理器来进行处理,通过 accept() 方法得到与这个客户端对应的连接,然后将该连接所关注的 READ / WRITE 事件以及它们对应的事件处理器注册到 Reactor 上。
③ 当 Reactor 监听到有读或者写事件发生时,将相关的事件派发给对应的处理器进行处理。
④ 每当处理完所有就绪的感兴趣的 I/O 事件后,Reactor 线程会再次执行 select/poll/epoll_wait 阻塞等待新的事件就绪并将其分派给对应的处理器进行处理。

单线程 Reactor 的程序执行顺序(下图左)。在没有事件的时候,线程等待在select/poll/epoll_wait 函数上。由于只有一个线程,因此事件是有顺序处理的。从“poll 返回之后” 到 “下一次调用 poll 进入等待之前” 指段时间内,线程不会被其他连接上了的数据或事件抢占(下图右)。


单线程 Reactor 时序.png

适用场景:在单核服务器中使用该模型比较适合
优点:由网络库搞定数据的收发,程序只需关心业务逻辑的处理
缺点:不适合CPU密集计算的应用,难发挥多核的威力。由于非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应。

方案8:reactor + 线程池

一个 reactor 线程 + 业务线程池 模式

reactor + 线程池.png

与单线程Reactor模式不同的是,添加了一个工作者线程池,并将非 I/O 操作从 Reactor 线程中移出转交给工作者线程池来执行。这样能够提高 Reactor 线程的网络 I/O 响应,不至于因为一些耗时的业务逻辑而延迟对后面 I/O 请求的处理。
程序执行的顺序图如下:


reactor + threadpool 时序.png

应用场景:计算任务彼此独立,而且IO压力不大,非常适合此方案。
如果 IO 的压力比较大,一个 reactor 忙不过来,可以试试 multiple reactors 的方案9。

方案9:one loop per thread

multiple reactors 模式

one loop per thread.jpg

这是 muduo 内置的多线程方案,也是 netty 内置的多线程方案。这种方案的特点是 one loop per thread,有一个 main reactor 负责 accept 连接,然后把连接挂在某个 sub reactor 中(muduo 采用 round-robin 的方式来选择 sub reactor),这样该连接的所有操作都在那个 sub reactor 所处的线程中完成。多个连接可能被分派到多个线程中,以充分利用 CPU。Muduo 采用的是固定大小的 reactor pool,池子的大小通常根据 CPU 核数确定,也就是说线程数是固定的,这样程序的总体处理能力不会随连接数增加二下降。另外,由于一个连接完全由一个线程管理,那么请求的顺序性有保证。这种方案把 IO 分派给多个线程,防止出现一个 reactor 的处理能力饱和。与方案8的线程池相比,方案9减少了进出 thread pool 的两次上下文切换,在把多个连接分散到多个 reactor 线程之后,小规模计算可以在当前 IO 线程完成然后发回结果,从而降低相应的延迟。
这是一个适应性很强的多线程 IO 模型。


multiple reactors 时序.png

方案11:one loop per thread + 线程池

multiple reactors + 业务线程池 模式

one loop per thread + 线程池.png

把方案8和方案9混合,既使用多个 reactors 来处理 IO,又使用线程池来处理业务逻辑计算。这种方案适合既有突发 IO (利用多线程处理多个连接上的 IO),又有突发计算的应用(利用线程池把一个连接上的计算任务分配到多个线程去做)。


multiple reactors + threadpool 时序.png

3. 总结

总结起来,推荐的多线程服务端编程模式为:event loop per thread + thread pool

这种模式能够很好的处理高并发和高吞吐量,而且编程非常清晰,main reactor 处理客户端的 connect 请求, sub reactors 处理所有的 TCP 连接的 I/O 读写,thread pool 处理具体的业务逻辑(对请求数据的具体处理)。

一个程序到底是使用一个 event loop 还是使用多个 event loop 呢? ZeroMQ 的手册给出的建议是,按照每千兆比特每秒的吞吐量配一个 event loop 的比例来设置 event loop 的数目。依据这条经验规则,在编写运行与千兆以太网上网络程序时,用一个 event loop 就足以应付网络 IO。

很多语言都有基于 Reactor 模式的高质量的网络库。
C:libevent / libev ,C++:muduo , Java:netty 等等。
对于学习 C++ Linux网络编程,特别推荐陈硕写的 muduo 网络库

上一篇 下一篇

猜你喜欢

热点阅读