并发网络服务器设计方案

下表是陈硕总结的 12 种常见方案。其中“多连接互通”指的是如果开发 chat 服务,多个客户连接之间是否能方便的交换数据。“顺序性” 指客户端顺序发送多个请求,那么服务器计算得到的多个响应是否按相同的顺序发还给客户端。
网络 - 图1
其中比较实用的有5种方案
网络 - 图2
表中 N 表示并发连接数目,C1 和 C2 是与连接数无关、与 CPU 数目有关的常数

  • 用银行柜台办理业务为比喻,简述各种模型的特点。
    1. 银行有旋转门,办理业务的客户从旋转门进出(IO)。
    2. 银行有柜台,客户在柜台办理业务(计算)。
    3. 要想办理业务,客户要先通过旋转门进入银行;办理完之后,客户要再次通过旋转门离开银行。
    4. 一个客户可以办理多次业务,每次都必须从旋转门进出(TCP长连接)。
    5. 旋转门一次只允许一个客户通过(无论进出),因为read()/write()只能同时调用其中一个。

方案2:thread-per-connection

经典的每个连接对应一个线程的同步阻塞 I/O 模式。
这是传统的 Java 网络编程方案 thread-per-connection, 在 Java1.4 引入 NIO 之前, Java 网络服务器程序多采用这种方案。这种方案的伸缩性受到线程数的限制,一两百个还行,几千个的话对操作系统的 scheduler 恐怕是个不小的负担
网络 - 图3
流程:
① 服务端的 Server 是一个线程,线程中执行一个死循环来阻塞的监听客户端的连接请求和通信。
② 当客户端向服务端发送一个连接请求后,服务端的 Server 会接受客户端的请求,accept() 从阻塞中返回,得到一个与客户端连接 socket。
③ 创建一个线程并启动该线程,构建一个 handler,将socket传入该 handler。在线程中执行 handler,这样与客户端的所有的通信以及数据处理都在该线程中执行。当该客户端和服务器端完成通信关闭连接后,线程就会被销毁。
④ Server 继续执行 accept() 操作等待新的连接请求。

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

方案5:单线程 reactor

网络 - 图4
流程:
① 服务端的 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 进入等待之前” 指段时间内,线程不会被其他连接上了的数据或事件抢占(下图右)。
网络 - 图5
适用场景:在单核服务器中使用该模型比较适合
优点:由网络库搞定数据的收发,程序只需关心业务逻辑的处理
缺点:不适合CPU密集计算的应用,难发挥多核的威力。由于非I/O的业务操作也在该线程上进行处理了,这可能会大大延迟I/O请求的响应

用银行柜台办理业务为比喻

  1. 银行有一个旋转门、一个柜台,每次只允许一名客户办理业务。
  2. 当有人在办理业务时,旋转门是锁住的(计算和IO在同一线程)。
  3. 银行要求客户应尽快办理业务,否则会阻塞其他堵在门外的客户。
  4. 如果一次办不完,应离开柜台,到门外等着,等银行通知再来继续办理(分阶段回调)。
    若客户少,这是经济、高效的方案;但如果场地大(多核),这就浪费了资源,只能并发(concurrent)不能并行(parallel)。

方案8。
用银行柜台办理业务为比喻

  1. 银行有一个旋转门,一个或多个柜台。
  2. 银行进门后有一个队列,客户在这里排队到柜台(线程池)办理业务。即在单线程Reactor后面接了一个线程池用于计算,可利用多核。
  3. 旋转门是不锁的,随时都可以进出。但排队会消耗时间,方案5的客户一进门就能立刻办理业务。
    另一做法是线程池里的每个线程有自己的任务队列,而不是整个线程池共用一个任务队列。好处是避免全局队列的锁争用,坏处是计算资源有可能分配不平均,降低并行度。

方案9:one loop per thread

网络 - 图6
这是 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 模型。
网络 - 图7
用银行柜台办理业务为比喻

  1. 该大银行包含方案5的多家小银行,每个客户进门时被固定分配到某间小银行中,他的业务只能由这间小银行办理,他每次都要进出小银行的旋转门。
  2. 大银行可同时服务多个客户。
  3. 同样要求办理业务时不能空等(阻塞),否则会影响分到同一间小银行的其他客户。
  4. 必要时可为VIP客户单独开一间或几间小银行,优先办理VIP业务。这跟方案5不同,当普通客户在办理业务的时候,VIP客户也只能在门外等着。

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

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

  • 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。

用银行柜台办理业务为比喻

  1. 银行有多个旋转门、多个柜台,旋转门和柜台之间没有对应关系。
  2. 客户进门时被固定分配到某一旋转门中(4.6易于实现线程安全的IO)。
  3. 进入旋转门后,有一个队列,客户在此排队到柜台办理业务。
    该方案的资源利用率可能比方案9更高,一个客户不会被同一小银行的其他客户阻塞,但延迟也比方案9略大