问题

本篇将梳理前面讲过的所有网络模型,并在此基础上探究这些模型所涉及的设计思路。相当于前面只讲了What和How部分,这里专门来补充Why部分。因为若是一开始就从原理讲起难免枯燥乏味且晦涩难通,而有了前面的铺垫再学起来就能增加可预知性,这时你的知识网络里已经构建了网络编程的基本框架,已经建立了它们之间的联系。

这篇文章就希望在此基础上,帮助你一步一步对新东西进行扩展重构,加深理解。这样不论学起来还是使用起来,都能游刃有余,左右逢源。

那么首先就从最开始的基本Socket说起。

这个阶段所开发的网络程序都是同步程序,一个客户端连接进来,服务器阻塞地使用 accept 接受,之后阻塞地发送,阻塞地接收。

当一个客户端的数据没有处理完时,其它客户端都得阻塞地等待连接,这样的服务器设计同时只能处理一位客户。若处理操作很短,那么其它客户端倒不会感到不满,因为速度很快它们根本察觉不到差异。而一但处理耗时较长,其它客户端就满心不悦,尤其是处于后面连接的客户端,只有等到前面的所有客户端处理完成才能被轮到。

这就好像在一家只有一个窗口的餐馆排除买餐,先来者先买,排在最后面的只有等到前面所有人买完了之后才能轮到。若是速食,那么很快就能买到,所以轮到的也快;而若是慢食,第一个人就要2分钟才能买到,那么第十个人就要20分钟。

所以这种方式效率极低,基本满足不了多少需求,而优点则是开发最简单。

问题总是倒逼着解决方案,所以一个个的思路堪堪而现。

多进程服务器

第一种做法是创建多个进程来对连接的客户端提供服务,这种方式称为 多进程服务器。比如在Linux上你可以使用 fork 函数创建一个调用的进程副本,它会复制正在运行的调用 fork 函数的进程。调用 fork 函数的主体称为 父进程,而通过父进程复制出来的进程称为子进程,它们都将执行 fork 函数调用后的语句。

所以这种方式就是每有一个客户端连接,都由父进程创建一个子进程来处理。这就好似每有一位顾客来买餐,都为其提供一个专门的餐馆,所以需要付出的代价极大,需要进行大量的运算和内存空间,而且各个进程间的数据交换也需要复杂的方法来完成。

多线程服务器

第二种做法是每有一位客户端连接,都开启一条线程来为其服务,这种方式称为「多线程服务器」。

比如在之前所写的HTTP服务器中,我们就对每一个并发连接的客户端开启了一条专门的线程。在服务下一条请求之前,这个线程同步地完成一个请求操作。当客户端请求访问指定文件时,在线程中同步地读取文件,再发送给客户端。

这就像每有一位顾客来买餐,都为单独地开一个窗口。而服务人员是有限的(即CPU核心数),若有4位服务人员,那么你开8个窗口,这4个人就得在这些窗口之间来回切换。虽说开了8个窗口,其实处理速度并没有得到提升。

这时,若是让窗口的数量和服务人员一致,那么效率往往更好。还有在结账时,只能由一位服务人员去处理(共享资源),若是一人处理一半又有另一个人来处理就造成收了二次收费。

所以总结这个模式的缺点就是:

  • 增加了性能开销(CPU频繁地进行上下文切换)
  • 增加了同步复杂度
  • 不可移植(并非所有系统都支持多线程,且系统之间实现可能有很大不同,难以达到行为一致)

Reactor模式

为了提高服务器的效率,就得想办法用最少的消耗来处理更多的请求。在前面两种方法中,要么消耗大量内存,要么增加CPU开销,由于CPU性能和内存空间都有瓶颈,所以就造成了极大的浪费。

若能将所有请求都放到一个管理中心去统一管理,处理完成后再通知相应的客户那么将可节省很多资源。

举个例子,由于顾客日益增多,所以餐馆便安装了一个点餐系统。此时,顾客无需等待,可以直接点餐,点餐系统负责记录每位顾客的点餐信息,并分派给厨师去做。当餐做好后,再由点餐系统对相应顾客发出通知“请xxx到柜台取餐”,顾客只需竖起耳朵听着就好。

这就是「I/O多路复用」(I/O Multiplexing)的思路,也叫「事件驱动模型」(Event-driven)。

但是因为只有一位厨师,所以对于同时点餐的顾客,就不能同时处理,此时就要将点餐时间划分为非重叠空隙,在不同的时间段对用户进行处理。这就叫做「时分多路复用」(Time Division Multiplexing,TDM)。

若是通知一次比较费时,那么点餐系统不必在只有一位客户点餐时就通知厨师,可以一次性发送几位顾客的需求,之后厨师再分析出这几位顾客的需求。这就叫做「频分多路复用」(Frequency Division Multiplexing,FDM)。

前面说过,当餐做好后,点餐系统需要发出通知,那么此时必须从已记录的客户信息中解析出对应的客户,都能通知给正确的用户。那么这部分操作就叫做「多路分解」(Demultiplexing),多路复用与多路分解的关系可参考这张图:

Mux-Demux

可以看到,通过引用复用技术,减少了进程数与线程数,只需一条线程或进程来处理请求。

通过这个思路,经过不断探索与经验积累,形成了一套开发服务器的模式,而这个模式就是著名的「Reactor模式」(中译为反应器模式)。

Reactor模式允许请求事件被应用到多路复用并分派到服务请求中,所有请求被从一个或多个客户端投递到Reactor(即前面所述之管理中心),如此便反转了程序的控制流,程序无需主动去监听客户端的消息,只需等待Reactor通知便好。

那么现在来看看Reactor模式的UML结构图。

Reactor UML

Reactor 是响应中心,它的职责是同步地等待指定事件,多路分解它们到负责处理这些事件相关的事件处理程序。

Event Handler 就是一个抽象的事件处理程序,这遵守了「依赖倒转原则」,即抽象不应该依赖于细节,细节应该依赖于抽象。具体的事件处理程序可以从其继承,根据多态原理,可以提供统一的接口,降低了程序的耦合性。

最后,Synchronous Event Demultiplexer 就是同步事件多路分解,它的作用是可以随时监听Reactor上所注册事件的通知。

大家应该已经猜到,select模式就是使用的此模式。若忘记了,可点击跳转网络模型之select

其中的Reactor就指的是select模式中的文件描述符(fd_set),它可以集中管理多个文件描述符,我们可以使用FD_ZERO/FD_SET/FD_CLR/FD_ISSET 来执行清除/设置/移除/检测操作,从而将所有的socket统一交给了文件描述符处理。

而所谓的事件处理程序就是我们在select例子中所编写的 RequestHandler,这个例子很简单,所以并没有复杂的处理。通过 Synchronous Event Demultiplexer 提供的select函数,可以得到发生事件的文件描述符,从而对发生该事件的套接字进行处理。当然也可以添加别的处理,比如专门处理HTTP请求的,专门处理聊天信息的,专门处理文件传输的,此时就应该使用抽象事件处理程序。

这个模式带来了一些优势,第一是具有可移植性,很多系统都支持此模式,比如windows和linux都可使用此模式进行开发;第二是开销低,因为是单线程的,所以并不需要同步操作和上下文切换;第三是将程序逻辑与分发机制解耦而实现了模块化,程序员只需负责实现具体的事件处理程序,然后就可复用Reactor的多路复用和分派机制。

对于缺点大家也都知道,这里就再次总结下:

  • 缺乏多线程支持:这是最主要的缺点,因为select不允许多个线程在同一描述符集上的事件循环中等待,所以此模型无法高效利用硬件的并发优势,从而不适合开发高效率的程序。
  • 无法支持大量并发客户端:首先 fd_set 在系统上的支持有限,其次就算可支持更多,在监听消息时也要依次遍历确认发生消息的文件描述符,每次都是 O(N) 线性复杂度。这主要是因为该模式在事件多路分发层序列化了所有事件处理。

Proactor模式

由于Reactor模式的缺点,在支持异步操作的系统上并非最佳选择,为了在这些系统上发挥出更好的性能,便发展出了「Proactor模式」(中译前摄器模式)。

回到之前的例子,由于餐馆地方有限,所以无法同时接纳大量用户,而此时外卖上线了,所以该餐馆加入了外卖服务。此时直接服务用户的就是外卖平台,餐馆来做后台工作。用户可以直接在外卖平台上点餐,由外卖平台负责启动所有的工作,它通知商家做单,通知外卖人员接单。

其中,外卖人员负责前台工作,餐馆负责后台工作。外卖人员的工作就是处理事件,真正的复杂工作交给餐馆去处理,当餐馆做好餐后通知外卖人员,外卖人员获取这些通知,并主动地将餐送到用户手中。

在这期间,用户无需一直等待,他可以去做其他事情,因为外卖到了会主动打电话通知他的。用户只需在接到电话后做主体的处理就好了(就是吃啦>:)。

此处还有一个注意点,就是一位外卖人员一般不会只接一单就去店里取货,这样也会造成资源浪费,所以一般他会凑够几单一起处理(即多线程的思路)。如此,即使在高峰时期,也能够尽量满足更多用户的需求。

在这之中,用户无需排队等候,完全可以自己去做其它事情,这就是所谓的「异步操作」。在前面所讲的几种方式中,用户都得在餐馆中等候,排队时必须原地等候,点餐时必须监听通知,无法去做其它的事,这就是所谓的「同步操作」。

可以发现,我们的生活模式也在不断更新,这不仅节省了时间,而且提高了效率。让专门的人去负责专门的事,如此协调,所有人都能获得共赢,这也是社会进步的必然道路。

刻鹄类鹜,软件科学所发展出的Proactor模式也经历了同样的发展道路。与被动地等待指定事件到达并做出响应的Reactor模式不同,Proactor模式中的客户端通过主动地启动一个或多个异步操作请求来在程序中启动控制,处理事件。

接着往下讲之前,该祭出Proactor模式的UML结构了。

Proactor UML

这个图基本组件都包括了,但是稍有简化,这些再给大家一张图来协同参考。

Proactor

当程序发起异步操作后,前摄启动器(Proactive Initiator)注册一个完成处理函数(Completion Handler)和一个完成分派器(Completion Dispatcher)的引用到异步操作处理器(Asyncronous Operation Processor)。

异步操作(Asynchronous Operation)即那些异步接受、异步读、异步写操作,当异步操作完成时,异步操作处理器将程序通知委托给完成分派器,完成分派器再负责将通知分派到所注册的回调函数。这里的异步操作处理器由系统负责实现,异步操作由其运行到完成。

那么现在,大家应该先自己对应上面所举之例和Proactor模式的分析理清这些组件是如何共同协调完成任务的。这里有一张表示它们之间运作的时序图可供大家参考。

Proactor sequence chart

IOCP中的Proactor模式

接着结合IOCP模型来说说此模式。IOCP模型底层采用Proactor模式设计,其核心组件Proactor就是 Completion Port 对象,异步操作则是通过重叠IO实现的。

在该模式中的开发中,首先通过 Initiator 组件将服务器套接字与CP对象连接,那么异步接受操作将由系统所实现的异步操作处理器去处理,当事件处理完成后,我们可通过CP对象获取相应的操作,并为其调用相应的回调函数来专门处理。

当有用户连接时,Initiator 又将用户套接字与CP对象连接,之后,异步操作的读取、发送数据就由系统异步操作处理器去异步地处理,当有相关操作时,依旧可以通过CP对象获取到相应的操作,调用回调函数对其进行相关的处理。

ASIO中的Proactor模式

最后,再来结合ASIO讨论一二。

那么首先,一起来看看boost官方给出的结构图。

ASIO Proactor

经过前面的讲解,这个图对大家来说应该已经相当简单了吧。

在asio中,io_service(亦即 io_context )就是该库的核心组件Proactor,这里稍微提醒一下,Proactor就相当于前面例子中的外卖人员,希望大家已经理解其重要性。

所以在asio中所有操作都需要 io_service 的参与。asio将 acceptor 单独提出,需要传入 io_service,这就相当于将IOCP中服务器套接字与CP对象连接这部分操作进行了封装处理,将接受操作单独分离出来,方便程序员建立模块化处理。其它操作依旧放在socket中,让程序员能专心对连接用户进行数据处理。

在使用acceptor和socket时,通过Initiator创建一个完成操作函数(Completion Handler),并开启异步操作(Asynchronous Operation),异步操作的真正执行是由异步操作处理器(Asynchronous Operation Proacessor)完成的。

当异步操作处理器完成操作后,io_service 通过异步事件多路分解(Asynchronous Event Demultiplexer)将完成事件队列(Completion Event Queue)中的事件分派给对应的完成回调函数,如此相互协作,重复完成用户的请求。

Proactor模式总结

最后,总结一下Proactor模式的优点。

  • 分离了程序的关注点:因为将独立于程序的异步机制与特定程序的功能解耦,使异步机制变成可复用的组件。这些组件知道如何将与异步操作关联的完成事件分解,并分派到相应的完成回调函数,特定的回调函数也知道如何执行特定的服务。如此,程序员只需关注对应的部分实现就好。
  • 提高了程序的可移植性:只要系统支持异步机制,那么就可通过复用系统接口来执行事件多路复用,提高了程序的可移植性。这些系统可以关注事件源上的事件,检测并报告发生的事件,比如windows上的IOCP与重叠IO。
  • 线程策略与并发策略解耦:由异步操作处理器代表前摄启动器执行复杂耗时的操作,程序无需专门产生线程来提高并发性。当然,这要系统提供并发机制。
  • 增强性能:无需多个线程的上下文切换消耗,亦无需循环监视,根据CPU核心数合理地开启线程,发挥最佳性能。
  • 简化程序同步:程序员只需负责实现完成处理回调函数,其它操作由异步机制完成,简化了操作,若是完成处理函数不生成额外的线程,那么也无需考虑同步问题。

当然,它依旧有一些缺点,使用IOCP开发过的朋友应该都经历过:

  • 调试困难:由于线程不可控制,且在通知过程中我们无法跟进行调试,给调试带来了困难,这种问题没有不好,一出现就准备无何止的改bug吧。
  • 开发困难:对新手来说,学起来困难很大,常常摸不清头绪,就算是直接使用asio已经封装好的库,也无法得心应手,更别说直接使用IOCP了。

就此结束了吗?当然没有,网络编程之路十分漫长,所需知识若撒天繁星,非一蹴而能就的。不过经过这么多篇文章的学习,大家基础自是够的。

接下来,多多躬行实践吧,将这些知识融汇贯通,以后若有谈及网络的文章绝对都是实践运用了,不会再写这些基础文章了。

网络系列经过十几篇文章到此就暂告段落,在公众号写文章不比博客,博客可随意而为,这里得深思熟虑,质量怎么说也得比博客精致,要不就重复制造垃圾了,浪费大家时间。如今回首看写的这些文章发现我竟能坚持下来,真是不易,希望能继续坚持,输出更多优质文章。

1 thought on “各种网络模型背后的设计思路”

  1. 提醒:在Proactor的举例中考虑到有与前面例子的关联性,有一处稍有不妥,不过刚好可以检测大家是否真正理解,希望大家能自己发现

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.