服务器模型总结

关于Reactor模式讲解请转 此文

服务器模型总结

其中“互通”指的是如果开发chat服务,多个客户连接之间是否能方便地交换数据(chat也是三大TCP网络编程案例之一)。

对于echo/httpd/Sudoku这类“连接相互独立”的服务程序,这个功能无足轻重,但是对于chat类服务却至关重要。

“顺序性”指的是在httpd/Sudoku这类请求响应服务中,如果客户连接顺序发送多个请求,那么计算得到的多个响应是否按相同的顺序发还给客户(这里指的是在自然条件下,不含刻意同步)。

方案0

方案0 这其实不是并发服务器,而是iterative 服务器,因为它一次只能服务一个客户。代码见[UNP]中的Figure 1.9,[UNP]以此为对比其他方案的基准点。这个方案不适合长连接,倒是很适合daytime这种write-only短连接服务。

. . .

方案1

方案1 这是传统的Unix并发网络编程方案,[UNP]称之为child-per-client或fork()-per-client,另外也俗称process-per-connection。这种方案适合并发连接数不大的情况。至今仍有一些网络服务程序用这种方式实现,比如PostgreSQL和Perforce的服务端。这种方案适合“计算响应的工作量远大于fork()的开销”这种情况,比如数据库服务器。这种方案适合长连接,但不太适合短连接,因为fork()开销大于求解Sudoku的用时。

方案2

方案2 这是传统的Java网络编程方案thread-per-connection,在Java 1.4引入NIO之前,Java网络服务多采用这种方案。它的初始化开销比方案1要小很多,但与求解Sudoku的用时差不多,仍然不适合短连接服务。这种方案的伸缩性受到线程数的限制,一两百个还行,几千个的话对操作系统的scheduler恐怕是个不小的负担。

方案3

方案3 这是针对方案1的优化,[UNP]详细分析了几种变化,包括对accept(2)“惊群”问题(thundering herd)的考虑。

方案4

方案4 这是对方案2的优化,[UNP]详细分析了它的几种变化。方案3和方案4这两个方案都是Apache httpd长期使用的方案。

方案5 - 单线程Reactor

以上几种方案都是阻塞式网络编程,程序流程(thread of control)通常阻塞在read()上,等待数据到达。但是TCP是个全双工协议,同时支持read()和write()操作,当一个线程/进程阻塞在read()上,但程序又想给这个TCP连接发数据,那该怎么办?比如说echo client,既要从stdin读,又要从网络读,当程序正在阻塞地读网络的时候,如何处理键盘输入?

又比如proxy,既要把连接a收到的数据发给连接b,又要把从b收到的数据发给a,那么到底读哪个?(proxy是附录A讲的三大TCP网络编程案例之一。)

Reactor出现的原因

一种方法是用两个线程/进程,一个负责读,一个负责写。[UNP]也在实现echoclient时介绍了这种方案。

另一种方法是使用IO multiplexing,也就是select/poll/epoll/kqueue这一系列的“多路选择器”,让一个thread of control 能处理多个连接。

“IO复用”其实复用的不是IO连接,而是复用线程。使用select/poll几乎肯定要配合non-blockingIO,而使用non-blocking IO肯定要使用应用层buffer。这就不是一件轻松的事儿了,如果每个程序都去搞一套自己的IO multiplexing机制(本质是event-driven事件驱动),这是一种很大的浪费。

感谢Doug Schmidt为我们总结出了Reactor模式,让event-driven网络编程有章可循。继而出现了一些通用的Reactor框架/库,比如libevent、muduo、Netty、twisted、POE等等。有了这些库,我想基本不用去编写阻塞式的网络程序了(特殊情况除外,比如proxy流量限制)。

Reactor的意义

Doug Schmidt指出,其实网络编程中有很多是事务性(routine)的工作,可以提取为公用的框架或库,而用户只需要填上关键的业务逻辑代码,并将回调注册到框架中,就可以实现完整的网络服务,这正是Reactor模式的主要思想。

而Reactor的意义在于将消息(IO事件)分发到用户提供的处理函数,并保持网络部分的通用代码不变,独立于用户的业务逻辑。

Reactor的具体模型

单线程Reactor的程序执行顺序下图中的左图所示。在没有事件的时候,线程等待在select/poll/epoll_wait等函数上。事件到达后由网络库处理IO,再把消息通知(回调)客户端代码。Reactor事件循环所在的线程通常叫IO线程。通常由网络库负责读写socket,用户代码负载解码、计算、编码。

注意由于只有一个线程,因此事件是顺序处理的,一个线程同时只能做一件事情。在这种协作式多任务中,事件的优先级得不到保证,因为从“poll返回之后”到“下一次调用poll进入等待之前”这段时间内,线程不会被其他连接上的数据或事件抢占(也就是说, 不会发生下图中的右图这种情况)。如果我们想要延迟计算(把compute()推迟100ms),那么也不能用sleep()之类的阻塞调用,而应该注册超时回调,以避免阻塞当前IO线程。

这种方案的优点是由网络库搞定数据收发,程序只关心业务逻辑;缺点在前面已经谈了:适合IO密集的应用,不太适合CPU密集的应用,因为较难发挥多核的威力。

另外,与方案2相比,方案5处理网络消息的延迟可能要略大一些,因为方案2直接一次read(2)系统调用就能拿到请求数据,而方案5要先poll(2)再read(2),多了一次系统调用。

方案6

方案6 这是一个过渡方案,收到Sudoku请求之后,不在Reactor线程计算,而是创建一个新线程去计算,以充分利用多核CPU。这是非常初级的多线程应用,因为它为每个请求(而不是每个连接)创建了一个新线程。这个开销可以用线程池来避免,即方案8。这个方案还有一个特点是out-of-order,即同时创建多个线程去计算同一个连接上收到的多个请求,那么算出结果的次序是不确定的,可能第2个Sudoku比较简单,比第1个先算出结果。这也是我们在一开始设计协议的时候使用了id 的原因,以便客户端区分response对应的是哪个request。

方案7

方案7 为了让返回结果的顺序确定,我们可以为每个连接创建一个计算线程,每个连接上的请求固定发给同一个线程去算,先到先得。这也是一个过渡方案,因为并发连接数受限于线程数目,这个方案或许还不如直接使用阻塞IO 的thread-per-connection 方案2。

方案7与方案6的另外一个区别是单个client的最大CPU占用率。在方案6中,一个TCP连接上发来的一长串突发请求(burst requests)可以占满全部8个core;而在方案7中,由于每个连接上的请求固定由同一个线程处理,那么它最多占用12.5%的CPU资源。这两种方案各有优劣,取决于应用场景的需要(到底是公平性重要还是突发性能重要)。这个区别在方案8和方案9中同样存在,需要根据应用来取舍。

方案8 - Reactor+ThreadPool

方案8 :

为了弥补方案6中为每个请求创建线程的缺陷,我们使用固定大小线程池。全部的IO工作都在一个Reactor线程完成,而计算任务交给thread pool。如果计算任务彼此独立,而且IO的压力不大,那么这种方案是非常适用的。

线程池的另外一个作用是执行阻塞操作。比如有的数据库的客户端只提供同步访问,那么可以把数据库查询放到线程池中,可以避免阻塞IO线程,不会影响其他客户连接。另外也可以用线程池来调用一些阻塞的IO函数,例如fsync(2)/fdatasync(2),这两个函数没有非阻塞的版本。

如果IO的压力比较大,一个Reactor处理不过来,可以试试方案9,它采用多个Reactor来分担负载。

方案9 - Reactors In Threads

方案9 :

这是muduo内置的多线程方案,也是Netty内置的多线程方案。这种方案的特点是one loop per thread,有一个main Reactor负责accept(2)连接,然后把连接挂在某个sub Reactor中(muduo采用round-robin的方式来选择sub Reactor),这样该连接的所有操作都在那个sub Reactor所处的线程中完成。

多个连接可能被分派到多个线程中,以充分利用CPU。

muduo采用的是固定大小的Reactor pool,池子的大小通常根据CPU数目确定,也就是说线程数是固定的,这样程序的总体处理能力不会随连接数增加而下降。

另外,由于一个连接完全由一个线程管理,那么请求的顺序性有保证,突发请求也不会占满全部8个核(如果需要优化突发请求,可以考虑方案11)。
这种方案把IO分派给多个线程,防止出现一个Reactor的处理能力饱和。

与方案8的线程池相比,方案9减少了进出thread pool的两次上下文切换,在把多个连接分散到多个Reactor线程之后,小规模计算可以在当前IO线程完成并发回结果,从而降低响应的延迟。

这是一个适应性很强的多线程IO模型,因此陈硕把它作为muduo的默认线程模型

方案10

方案10 这是Nginx的内置方案。如果连接之间无交互,这种方案也是很好的选择。工作进程之间相互独立,可以热升级。

方案11 - Reactors+Thread Pool

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

哪些是实用的模型

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

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

方案5

这间小银行有一个旋转门、一个柜台,每次只允许一名客户办理业务。而且当有人在办理业务时,旋转门是锁住的(计算和IO在同一线程)。为了维持工作效率,银行要求客户应该尽快办理业务,最好不要在取款的时候打电话去问家里人密码,也不要在通过旋转门的时候停下来系鞋带,这都会阻塞其他堵在门外的客户。如果客户很少,这是很经济且高效的方案;但是如果场地较大(多核),则这种布局就浪费了不少资源,只能并发(concurrent)不能并行(parallel)。如果确实一次办不完,应该离开柜台,到门外等着,等银行通知再来继续办理(分阶段回调)。

方案8

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

方案9

这间大银行相当于包含方案5中的多家小银行,每个客户进大门的时候就被固定分配到某一间小银行中,他的业务只能由这间小银行办理,他每次都要进出小银行的旋转门。但总体来看,大银行可以同时服务多个客户。这时同样要求办理业务时不能空等(阻塞),否则会影响分到同一间小银行的其他客户。而且必要的时候可以为VIP客户单独开一间或几间小银行,优先办理VIP业务。这跟方案5不同,当普通客户在办理业务的时候,VIP客户也只能在门外等着(见图6-11的右图)。这是一种适应性很强的方案,也是muduo原生的多线程IO模型。

方案11

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

应该使用几个event loop

一个程序到底是使用一个event loop还是使用多个event loops呢?

ZeroMQ的手册给出的建议是,按照每千兆比特每秒的吞吐量配一个event loop的比例来设置event loop的数目,即muduo::TcpServer::setThreadNum()的参数。

依据这条经验规则,在编写运行于千兆以太网上的网络程序时,用一个event loop就足以应付网络IO。如果程序本身没有多少计算量,而主要瓶颈在网络带宽,那么可以按这条规则来办,只用一个event loop。另一方面,如果程序的IO带宽较小,计算量较大,而且对延迟不敏感,那么可以把计算放到thread pool中,也可以只用一个event loop。

值得指出的是,以上假定了TCP连接是同质的,没有优先级之分,我们看重的是服务程序的总吞吐量。但是如果TCP连接有优先级之分,那么单个event loop可能不适合,正确的做法是把高优先级的连接用单独的event loop来处理。