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

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 一起使用?

  • 没有人真的会用轮询(busy-pooling)来检查某个 non-blocking IO 操作是否完成,这样太浪费 CPU cycles.
  • IO-multiplex 一般不和 blocking IO 用在一起,因为 blocking IO 中的 read() / write() / accept() / connect() 都有可能阻塞当前线程,这样当前线程就没有办法处理其他 socket 上的 IO 事件了。比如某个 socket 接收缓冲区有新数据分节到达,然后 select 报告这个 socket 描述符可读,但随后协议检查发现有错误的校验然后这个分节被丢弃,这时调用 read 则无数据可读就会一直阻塞。再比如,多个线程对某个 socket 进行监听,当新的连接到达后,多个线程的 select 都会被唤醒,但是最后只有1个线程可以 accept,其他线程都将被阻塞(一般来说不会用多个线程去监听新的连接到来,这里只是用于举例说明而已)。

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

  • 需要输出缓冲区(output buffer)的原因:
    比如一个场景,程序想通过 TCP 连接发送 100k 字节的数据,但是在 write() 调用中,操作系统只接受了 80k,肯定不能一直等待,因为不知道会等多久。这时程序需要尽快交出控制器,返回 event loop。剩余的 20k 字节数据应该由网络库接管,把它保存在该 TCP connection 的输出缓冲区(output buffer)里,然后注册 POLLOUT 事件,一旦 socket 变得可写立马就立刻发送数据。如果写完了 20k 字节,网络库应该停止关注 POLLOUT,以免造成 busy loop。

  • 需要输入缓冲区(input buffer)的原因:
    TCP 是一个无边界的字节流协议,接收方必须要处理 “收到的数据尚不构成一条完整的消息(俗称半包)” 和 “一次收到两条消息的数据(俗称粘包)” 等等情况。比如一个场景是,发送方 send 了两条 10k 字节的消息(共20k),接收方收到数据的情况可能是:

    • 一次性收到 20k 数据
    • 分两次收到,第一次 5k,第二次 15k
    • 分两次收到,第一次 15k,第二次 5k
    • 分两次收到,第一次 10k,第二次 10k
    • 分三次收到,第一次 6k,第二次 8k,第三次 6k
    • 其他任何可能

    那么网络库必然要应对 “数据不完整” 的情况,收到的数据先放到 input buffer 里,等构成一条完整的消息再通知程序的业务逻辑层。这通常是编解码层的职责。网络库在处理 “socket 可读” 事件的时候,必须一次性把 socket 里的数据读完(从操作系统 buffer 搬到应用层 buffer),否则会反复出发 POLLIN 事件,造成 busy-loop。

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模式的角色构成

  • Handle:(Windows 下句柄或者 Linux 文件描述符)是由操作系统提供的资源,是事件产生的发源地。事件既可来自于外部(比如客户端的连接请求,客队发送过来的数据等),也可以来自于内部(比如操作系统产生的定时事件等)。
  • Synchronous Event Demultiplexer:(同步事件分离器)它是一个系统调用,用于等待事件的发生,该方法在调用时会被阻塞,一直阻塞到同步事件分离器上有事件产生为止。在 Linux 下,同步事件分离器就是常用的 I/O 多路复用机制(IO multiplexing)select、poll、epoll 等。在 Java NIO 中,同步事件分离器对应的组件就是 Selector,对应的阻塞方法就是 select 方法。
  • Event Handler:(事件处理器)由多个回调方法构成,这些回调构成了与应用相关的对于某个事件的反馈机制。
  • Concrete Event Handler:(具体事件处理器)是事件处理器的实现。它本身实现了事件处理器的各种回调方法,从而实现了特定业务的逻辑。
  • Initiation Dispatcher:(初始分发器)实际上就是 Reactor 角色。它本身定义了一些规范,这些规范用于控制事件的调度,同时又提供了应用进行事件处理器的注册、删除等方法。它是整个事件处理器的核心所在,它会通过同步事件分离器来等待事件的发生。一旦事件发生,分发器首先会分离出每个事件,然后调用事件处理器,最后调用相关的回调方法来处理这些事件。回调方法都是由 IO 线程里的 EventLoop 来调用的。

Reactor 模式处理流程

Reactor 模式处理流程.png

  • 1 应用初始化 Initiation Dispatcher。
  • 2 当应用向 Initiation Dispatcher 注册 Concrete Event Handler 时,应用会标识出该事件处理器希望 Initiation Dispatcher 在某种类型的事件发生时向其通知,事件与 Handle 关联。
  • 3 Initiation Dispatcher 要求注册在其上面的 Concrete Event Handler 传递内部关联的 Handle,该 Handle 向操作系统标识了事件处理器。
  • 4 当所有的 Concrete Event Handler 都注册到 Initiation Dispatcher 上后,应用会调用 handle_events 方法来启动 Initiation Dispatcher 的事件循环,这时 Initiation Dispatcher 会将每个 Concrete Event Handler 的 Handle 合并起来,并使用 Synchronous Event Demultiplexer 来等待这些 Handle 上事件的发生。
  • 5 当与某个事件源对应的 Handle 变为 ready 时(比如 socket 变为 ready for reading),Synchronous Event Demultiplexer 便会通知 Initiation Dispatcher。
  • 6 Initiation Dispatcher 会触发事件处理器的回调方法。从而响应这个处于 ready 状态的 Handle。当事件发生时,Initiation Dispatcher 会将被事件源激活的 Handle 作为 “key” 来寻找并分发恰当的事件处理器回调方法。
  • 7 Initiation Dispatcher 调用特定的 Concrete Event Handler 的 handle_event(event_type) 回调方法来响应其关联的 Handle 上发生的事件。所发生的事件类型可以作为该方法参数并被该方法内部使用来执行额外的特定于服务的分离与分发。

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

  • event loop 用作 non blocking IO 和定时器。
  • 线程池用来做业务逻辑计算,具体可以是任务队列或者消费者-生产者队列。

这种模式能够很好的处理高并发和高吞吐量,而且编程非常清晰,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 网络库

你可能感兴趣的:(常见的并发网络服务器设计方案)