网络编程范式:高性能服务器就这么回事原创
本文为《高性能网络编程游记》的第五篇 “网络编程范式:高性能服务器就这么回事”。
随着互联网技术的兴起,以及互联网应用用户的井喷式增长,对服务器能够处理的并发连接数量也提出了更高的要求。要打造一个高性能发服务器,必须面临C10K,甚至C10M的问题。
这也是我们在做性能测试的时候,也需要考虑的一个问题,怎么才能能够让服务器的性能发挥到极致?
接下来本文会重点介绍网络编程的各种范式,以及实现高性能网络服务器的方法。
阅读完本文,你将了解到:
-
C10K问题的挑战与核心难点;
-
各种网络编程的范式以及其优缺点;
-
Reactor和Proactor模式,了解目前业界最常见的实现高性能服务器的方法
1、C10K问题
1.1、C10K问题描述
C10K问题:C10k是用于同时处理一万个连接的缩写。
请注意,并发连接数与每秒请求数不同,尽管它们很相似:每秒处理多个请求需要高吞吐量(快速处理它们),而大量并发连接则需要高效的连接调度
。换句话说,每秒处理请求量与处理请求的速度有关,而能够处理大量并发连接的系统不一定必须是快速的系统,只需要确保在有限的时间(不需要固定)内可以返回每个请求的响应即可。
下面讲下C10K问题会面临哪些挑战。
1.2、C10K问题挑战
1.2.1、操作系统
文件句柄限制
每个连接到服务器的请求,都会产生一个套接字对应的文件描述符。
在Linux下面,我们可以使用ulimit -n命令查看单个进程可以打开的最大文件句柄数量,当然也包括socket连接的文件句柄,系统默认值是1024。
如果单个进程打开的文件句柄数量超过了系统定义的值,那么就会提示以下异常:
1too many files open
注意,出现这种异常,可能是文件句柄设置太小了,也可能是应用程序出现问题,导致线程堆积,耗尽文件句柄,需要根据实际情况进行判断。
利用ulimit命令可以对资源的可用性进程控制。
我们可以通过ulimit -n
命令来查看当前系统设置的大小。
1ulimit -Hn # 查看硬限制,这个参数一般由管理员设置
2ulimit -Sn # 查看软限制,这个参数最大不能超过硬限制的大小
查看某个进程以及打开的文件数:
1ll /proc/进程号/fd
2ll /proc/进程号/fd | wc -l
可以通过ulimit -HSn xx临时修改文件句柄限制数量。
永久修改:
1vim /etc/security/limits/conf
内存
每个TCP连接都会有自己的接收缓冲区和发送缓冲区,如果是每个请求创建一个线程,那么每个线程堆栈也需要消耗一定的内存。
我们可以通过以下命令查看TCP接收缓冲区和发送缓冲区的大小:
1[root@VM_0_12_centos ~]# cat /proc/sys/net/ipv4/tcp_wmem
24096 16384 4194304 # 最小分配值,默认分配值,最大分配值
3[root@VM_0_12_centos ~]# cat /proc/sys/net/ipv4/tcp_rmem
44096 87380 6291456 # 分别为最小分配值,默认分配值,最大分配值
在任何体系结构上,您可能需要减少为每个线程分配的堆栈空间量,以免耗尽虚拟内存。如果使用的是pthreads,则可以在运行时使用pthread_attr_init()进行设置。
假设一个连接占用200k缓存,那么10k连接占用不到2G的内存,这对于64位服务器来说,是很微不足道的资源。
其他
您可以以合适的价格购买带有2 GB RAM的1000MHz机器和1000Mbit / sec以太网卡。
假设每个客户端50KHz,100Kbytes和50Kbits / sec,从磁盘上获取4 KB的数据并将其发送给网络,这足以应付20000个客户端连接。[1]
因此,硬件不再是瓶颈。
当并发连接超过10k的时候,虽然硬件是支持,但是如果应用程序层面没有设计好,依然不能正常提供服务,这就是C10K的问题最重要的挑战。当然,你也可以购买大量的服务器,但是如果每台服务器不能充分的发挥作用,硬件的闲置率就会太高了,浪费资金。
1.2.2、应用程序层面
要是以更高效的方式支撑10K并发量的处理效率,那就得从编程模型上面多下功夫了。
前面几篇文章,我们分别介绍了Socket API,以及阻塞模型,非阻塞模型,IO复用,异步IO等IO模型,可以发现为了更高效的利用系统资源(CPU资源,线程资源),新的技术不断涌现,把这些新技术用起来,就可以让硬件充分发挥其作用了。
也许有人会说自己公司的业务还不用考虑C10K的问题,但是如果业务持续增长,迟早还是需要补上这个技术债务的,既然硬件和配套技术业界都很成熟了,成本低廉,为何不提早布局呢?就好比我们以前买大哥大只是为了打电话,后来换成了功能机、智能机,到现在,大家买个最新的iPhone要是还只是用来打电话,那就有点浪费资源了。业务上可以避免过度设计,但是技术上却不能短视。IPv4设计的短视,导致出现了IPv6,select函数设计的短视,导致出现了poll,TCP拥塞控制以及对移动网络的短视,导致出现了QUIC…
举个很常见的例子:定时任务为了处理待确认的数据,从数据库中一次性查找所有的待确认记录的集合进行遍历,刚上线没问题,可是随着业务量增长,待确认的记录集合越来越大,到了某一天,MySQL就吃不消,或者JVM就OOM了。
据说现在大家都喜欢讨论C10M问题了…
为了探索应对高并发系统的网络编程模式,我们先来介绍下常用的客户/服务器程序设计范式。我们将主要围绕以下两点来说明如何构建高性能的网络程序:
-
如何高效处理IO,让系统资源充分合理地被使用,究竟是使用阻塞IO还是非阻塞IO,或者IO复用?
-
如何分配进程线程来支撑高并发请求场景,究竟是使用单线程还是多线程,单进程还是多进程?
为了能够开发出高效,健壮的并发和联网应用程序,我们需要引入对应的并发设计模式,传统的实现方式有:
-
基于线程;
-
基于事件驱动。
接下来我们分别进行介绍。
2、客户/服务器程序设计思路
2.1、阻塞I/O + 子进程
这是比较传统的一种处理方式,并发服务器调用fork派生一个子进程来处理每个连接。
伪代码如下:
1for(;;) {
2 connfd = accept(listenfd, client_address, &clilen);
3 if (childpid = Fork() == 0) { // fork一个子进程
4 Close(listenfd); // 子进程中关闭监听套接字
5 read(connfd)
6 execute(connfd); // 处理业务逻辑
7 write(connfd)
8 exit(0); // 退出子进程
9 }
10 Close(connfd); // 父进程关闭已连接套接字
11}
给每个已连接套接字分配一个子进程,也就意味着可以直接在子进程中使用阻塞IO,这是最简单也是最古老的一种编程范式。Apache服务器就是这种方式运行的,而我们熟知的Redis和Nginx都是基于epoll的事件驱动实现的,后面会进一步介绍。
我们可以用下图进一步详细描述这种模型:
如上图:
-
我们可以把父进程称为Acceptor Process,即接收者进程,主要用于处理客户端连接并获取已连接套接字;
-
Acceptor Process把每个已连接套接字单独交给一个子进程处理,这个子进程同时处理了IO和具体的业务处理工作,所以也称为IO & worker process。
2.1.1、缺点
进程有以下缺点:
-
进程是通过fork系统调用创建的,而fork是昂贵的。fork需要把父进程的内存映像复制到子进程,并在子进程中复制所有描述符;
-
父子进程的信息传递需要进程间通信机制;
-
能够支持的最大连接数受限于服务器能够为用户分配多少子进程;
-
我们可以发现子进程同时处理了IO和业务工作,而IO也会导致进程阻塞,阻塞过程中,进程是处于空闲状态的?
-
我们创建进程越多,系统进程上下文切换开销也就越大。
为了避免进程开销的问题,我们可以使用轻量级进程(一种实现线程的方式,另外可以通过内核线程和用户线程的方式实现),也即我们所说的线程,线程的创建速度比进程块10~100倍。而且线程之间共享全局内存,使得线程间通信很容易。
多线程也是有利有弊,多线程导致了复杂的线程同步问题的产生,为此我们引入了Java的内存模型,以及通过volatile以及synchronized、final等关键字保证变量操作的原子性。我们在 如果有人给你撕逼Java内存模型,就把这些问题甩给他 一文中也有了比较深入的介绍。
2.2、阻塞I/O + 线程
进程占用的资源太大了,我们可以使用线程取代。线程是一种轻量级的资源模型。
伪代码如下:
1for(;;) {
2 connfd = accept(listenfd, client_address, &clilen); // 获取已连接套接字
3 pthread_create for connfd; // 为已连接套接字创建一个线程
4 thread_run(connfd); // 运行线程
5}
创建线程是比较耗资源的,为此,我们可以引入线程池来提升线程的使用效率。
伪代码如下:
1// 主线程
2create thread pool;
3for(;;) {
4 connfd = accept(listenfd, client_address, &clilen); // 获取已连接套接字
5 push_queue(connfd); // 把已连接套接字fd push到队列中
6}
7
8// 线程池中的每个线程处理逻辑
9pthread_detach(pthread_self());
10for(;;) {
11 int fd = pop_queue(); // 从队列中获取一个已连接套接字fd
12 do_service(fd); // 从socket读入数据,处理业务,输出数据到socket
13}
2.2.1、缺点
我们一旦获取到了已连接套接字之后,后面就会处理IO读和IO写操作,IO读写的效率影响了系统资源的利用率,接下来我们介绍IO资源使用问题的处理方法。
以下是一组来自Google研究员Jeff Dean的演讲主题为“构建大型分布式系统的建议”PPT[2]中的IO开销的数据:
10.5 ns .......... L1 cache reference
25 ns ............ Branch mispredict
37 ns ............ L2 cache reference
4100 ns .......... Mutex lock/unlock
5100 ns .......... Main memory reference
610,000 ns ....... Compress 1K bytes with Zippy
720,000 ns ....... Send 2K bytes over 1 Gbps network
8250,000 ns ...... Read 1 MB sequentially from memory
9500,000 ns ...... Round trip within same datacenter
1010,000,000 ns ... Disk seek
1110,000,000 ns ... Read 1 MB sequentially from network
1230,000,000 ns ... Read 1 MB sequentially from disk
13150,000,000 ns .. Send packet CA->Netherlands->CA
可以看出,IO开销是昂贵的。
并且,随着并发数的增加,线程上下文切换的开销越来越大,多线程或者多进程模式并不能很好的适应高并发场景。
为了解决这个问题,我们可以引入I/O事件驱动模型。常见的解决方案是:Reactor模式和Proactor模式,两种解决C10K这类问题,提高系统并发量的编程模型。
2.3、Reactor反应堆模型
Reactor反应堆模型,属于I/O事件驱动模型,我们也称为Event Loop模型。接下来我们先说明下这几个概念。
2.3.1、事件驱动模型
我们在事件驱动模型与观察者模式一文中介绍过事件驱动模型架构,这里在大致说明下:
包含:
-
事件生产者
:产生一连串的事件,也叫事件源
;如界面上的按钮是一个事件源,能够产生点击事件; -
事件消费者
:监听并且消费事件,也叫事件监听器
; -
事件
:或者成为事件对象,是事件源和事件监听器之间的信息桥梁,整个事件模型驱动的核心;
这就是基于事件驱动的架构,但是具体如何实现,有许多方式:
-
基于PUB/SUB模型;
-
时间流模型
-
…
2.3.2、Event Loop事件循环模型
Event Loop是一个程序结构或称为设计模式,是事件驱动模型的一种实现。Event Loop通过向某个内部或外部“事件提供程序”发出请求(通常会进入阻塞状态)来工作,拿到结果后,调用相关的事件处理回调程序。
Event Loop一般设计为如下的分层架构[9]:
-
事件源
:产生一连串的事件,如界面上的按钮是一个事件源,能够产生点击事件; -
信号分离器
:主要职责是等待事件在事件源集上发生,然后将其分派到其相关的事件处理程序回调; -
事件处理器
:执行应用程序指定的响应回调。
下面通过JavaScript来说明Event Loop。
2.3.2.1、浏览器端
JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。这个模型与其它语言中的模型截然不同,比如 C 和 Java。[3]
下面是这个模型的描述:
如上图:
-
在V8引擎中运行JS代码,运行过程中,产生的对象分配在堆中;
-
每当有一个新的方法调用,都会在栈中产生一个新的栈帧Frame,执行完则从栈中弹出,这和JVM虚拟机的运行类似;
-
栈帧可能会触发一个外部API[4]的调用,进而触发在事件队列中加入各种回调事件;
-
在浏览器,每当一个事件发生并有一个事件监听器绑定在该事件上的时候,一个事件消息就会被添加到消息队列中。例如点击了一个按钮元素,就会把这个按钮元素的点击操作封装成事件添加到消息队列中;
-
JavaScript的事件循环模型永不阻塞。处理 I/O 通常通过事件和回调来执行;
只要栈中的代码执行完毕,Event Loop主线程就会去读取事件队列,依次执行那些已完成事件所对应的回调函数。
2.3.2.2、服务器端
服务器端的,即Node.js,与浏览器的机制有所不同,我们在下一篇文章中进行介绍。
2.3.3、Reactor
而Reactor
反应堆模型,用到了Event Loop事件循环技术。
一看到反应堆这个名词,一般人会立刻联想到核反应堆,核裂变。核反应堆通过合理布置核燃料,使得在无需补加中子源的条件下能在其中发生自持链式核裂变过程。
下图来源WIKIPEDIA:
而我们提到的Reactor模式,是处理并发I/O比较常见的模式,使用同步IO实现。会将所有要处理的IO事件注册到一个IO多路解复用器中,同时主线程阻塞在该多路解复用器上,直到有IO事件准备就绪了,多路解复用器从阻塞处返回,把准备好的IO事件分发到事件处理器中(也称为回调函数)。
Reactor是基于非阻塞同步IO + 事件驱动技术[ + 线程池构建] 的一种并发模型,用于处理通过一个或多个输入同时交付给服务处理程序的服务请求。然后,服务处理程序对传入的请求进行多路分解,并将它们同步分发到关联的请求处理程序。
Reactor是一种事件驱动模式,与观察者模式类似,区别是:观察者模式与单个事件源关联,而Reactor模式可以与多个事件源关联。
Reactor模式通过poll、epoll等IO分发技术实现的一个无线循环的事件分发程序。获取到一个已连接事件之后,会触发添加监听更多的读写事件,有点类似裂变的过程,所以反应堆模式的命名就这样来了。
2.3.4、Reactor模型
反应堆模型的类图如下:
整体结构还是比较简单的:
-
Handle
:描述符,或称为句柄,代表着操作的资源,可以是文件句柄,Socket套接字描述符等;把这个描述符注册到Synchronous Event Demultiplexer
(同步事件多路解复用器)中,进行监听Handle上发生的事件,如:Connect,Read,Write,Close等事件; -
Synchronous Event Demultiplexer
(同步事件多路解复用器):该组件阻塞等待被监听的事件集,等待事件发生,一般使用IO复用技术实现,如select、poll、epoll; -
Reactor
:反应堆程序的入口,用于管理Event Handler
,负责注册、移除Event Handler。通过调用Synchronous Event Demultiplexer
的select()
方法阻塞等待事件的发生,当事件发生时,根据发生事件的描述符和事件类型,找到对应的Event Handler对事件进行响应处理,即调用Event Handler的handle_event()方法; -
Event Handler
:事件处理器,处理具体的IO事件,对IO事件作出具体的响应,所以这个组件中定义的方法也成为回调方法,Concrete Event Handler则是具体的事件处理器程序的实现。
Reactor模式引入的结构遵循“好莱坞原理”,“反转”了应用程序内的控制流。简单来说,就是:不用问我快递送到哪里了,快递送到目的地的时候,我会通知你来取快递。
执行序列图如下:
-
注册具体的事件处理器
ConcreteEventHandler
(一般称为Acceptor Handler)到Reactor
中,如果是网络IO框架,这一步对应创建监听套接字,并且把监听套接字事件处理器注册到Reactor中; -
Reactor
调用ConcreteEventHandler
的get_handle()
获取事件的描述符; -
获取事件的描述符后,让
描述符
与ConcreteEventHandler
关联起来,方便后续查找描述符的Handler; -
执行
Reactor
的handle_event()
方法阻塞循环等待IO事件,这一步也就意味着开启了Event Loop; -
handle_event()
会进一步调用Synchronous Event Demultiplexer
的select()方法阻塞等待IO事件; -
一旦有IO事件准备好,则
select()
调用从阻塞状态被唤醒,并拿到准备好的描述符handles
做后续的处理; -
Reactor
拿到select的结果后,依次遍历handles
,调用ConcreteEventHandler
的handle_event()
方法执行IO事件回调; -
最终调用
do_service()
方法执行最终的业务逻辑,这里的do_service()一般为抽象方法由具体的ConcreteEventHandler实现。
上面是不是还比较抽象呢?没关系,我们举例子进一步说明。
案例:基于事件驱动的日志服务
接收新连接
如上图:
-
日志服务注册
日志Acceptor Handler
到Reactor
中,用于处理客户端连接请求事件; -
日志服务调用
handle_events()
方法开启Reactor的Event Loop,该方法进一步调用Synchronous Event Demultiplexer
的select()
方法等待连接的到达; -
一个客户端连接请求尝试连接到服务器;
-
Reactor
接收到连接请求,从select
阻塞处返回,通知Logging Acceptor
; -
Logging Acceptor
获取一个已连接套接字handle描述符
; -
Logging Acceptor
创建一个Logging Handler
来服务于这个新接收的连接handle描述符; -
Logging Handler
注册自己持有的handle到Reactor
中,这样,当handle有IO事件到达的时候,就可以触发Logging Handler进程处理了。
处理IO事件
如上图:
-
客户端发起一个日志记录请求,
Synchronous Event Demultiplexer
通知Reactor其handle set中已有准备好IO的事件,即ClientA描述符的读事件已经准备好了; -
Reactor通知ClientA描述符关联的
Loggin Handler
进行事件处理; -
Logging Handler
开始以非阻塞的方式接受数据; -
当接收完毕之后,
Logging Handler
开始处理数据,最后把结果write
出去; -
Loggin Handler
返回,Reactor继续执行Event Loop
,当代新事件的到达。
可以发现有个性能问题:Loggin Handler的处理阻塞了Reactor的Event Loop线程,我们可以把Loggin Handler丢到单独的线程池中执行,后续会介绍这种模型。
Reactor的优势
-
遵循“好莱坞法则”,让关注点分离,各组件各司其职;
-
让程序模块化,增加可重用性和可配置性;
-
增加程序的可移植性;
-
尽可能降低并发控制的开销。
我们先从单线程版本从Reactor来说起。
2.3.5、单线程版本的Reactor
如上图,Reactor线程处理了所有的事情,包括:
-
Accept接收新连接,把新连接IO读写事件注册到同步事件多路解复用器;
-
执行dispatch调用多路解复用器阻塞等待IO事件;
-
分发事件到特定的Handler中处理;
当描述符可读的时候,才会去执行read,想要往socket写数据的时候,统一先写到输出缓冲区,在等到感知到可写事件的时候,再统一把输出缓冲区的数据写到网卡即可,从而避免了读和写的阻塞。
可以发现,通过dispatch方法,在dispatch方法中调用Synchronous Event Demultiplexer
进行IO轮训,避免了多线程开销,只有dispatch方法中的代码才会导致阻塞。
但是单线程版本中的Handler处理会阻塞Reactor的Event Loop线程的IO轮训,为此,我们可以把Handler丢到单独的线程池中进行处理。
2.3.6、主从Reactor
上面单线程版本的Reactor可能因为Event Handler处理速度太占用CPU时间,导致新请求进来的连接套接字不能及时通过Reactor注册到Synchronous Event Demultiplexer
中进行IO轮训,相关IO事件得不到处理,简单来说就是新的连接服务器不能及时处理,也就是IO分发效率不高。
而我们目前的服务器一般是是多核的CPU,通过使用多线程技术,可能更有效的提高CPU资源的利用率。我们可以同时创建多个Reactor线程,一个线程处理监听套接字的连接事件,作为主Reactor,其他线程处理已连接套接字的IO事件,作为从Reactor,如下:
如上图,该模型包含一个主Reactor线程,以及若干个从Reactor线程。
主Reactor线程只负责执行dispatch阻塞等待监听套接字的连接事件,当有套接字连接之后,随机选择一个Sub-Reactor,把已连接套接字的IO读写事件注册到选择的sub-Reactor线程中,Sub-Reactor线程负责阻塞等待已连接套接字的IO事件的到来,并且调用IO事件的Event Handler处理IO读写事件。这样就实现了IO的高效分发。
2.3.7、单线程版本的Reactor + Worker线程池
在单线程版本的Reactor中,compute这个操作是具体的业务计算操作,无法知道真实情况究竟会执行什么处理,为此,我们最好把这部分处理丢到一个线程池中,如下图:
2.3.8、主从Reactor + Worker线程池
结合2.3.7和2.3.8的优点,即可借助CPU多核的能力实现快速响应已连接套接字和感知已连接套接字IO事件,又可以把具体业务处理与IO处理互相解耦,互不影响。这种模式成为主从Reactor + Worker线程池模式,如下图:
这样,实际业务处理相关工作,都放到了Worker Thread pool中,Reactor反应堆只处理IO相关任务,实现了业务和IO的解耦。
2.3.9、Reactor模型的问题
但是基于IO复用的Reactor本质上并不是异步IO,当IO事件准备就绪时,还是需要让用户进程发起IO请求,把数据从内核拷贝到应用进程。而我们接下来介绍的Proactor则是真正基于异步IO实现的。
IO复用与异步IO的最大区别就是多进行了一次系统调用:
-
注册文件产生一个系统调用;
-
轮训IO事件产生一个系统调用;
-
读取IO数据产生给一个系统调用。
而系统调用是昂贵的,而AIO则可以把轮训和读取IO数据合并为一个系统调用。这正是Proactor模型的优势,接下里,我们详细介绍一下Proactor模型。
注意:Java AIO底层是要调用OS的AIO实现的,而目前Windows中提供了比较成熟的IOCP异步IO方案,而Linux的异步IO方案并不是很成熟,所以JDK并没有用到Linux的AIO,而是在用户空间使用epoll多路复用IO技术模拟异步IO,所以Java AIO不能说是完全异步的,只是简化了网络编程模型,相比于非阻塞多路复用模型更易于理解,开发更为简单。
2.4、Proactor模型[2]
Proactor主动器模式
是基于非阻塞异步IO
+ 线程池
+ 事件驱动
构建的一种并发模式,这种模式可以认为是反应堆模式的异步变体。
可以认为:Reactor模式下的IO操作,是在应用进程中执行的,Proactor中的IO操作是由操作系统来做的。
2.4.1、Proactor主动器模型
-
Handle
:描述符,或称为句柄,标识可以根据外部或内部请求生成完成事件的事件源,代表着操作的资源,可以是文件句柄,Socket套接字描述符等。 -
Completion Handler
:完成事件接口,定义了用于处理异步操作结果的事件处理器; -
Concrete Completion Handler
:具体的事件处理器; -
Proactor
:主动器,为应用程序提供Event Loop事件循环,将完成事件队列中的已完成事件多路分解到相关的完成事件处理器中; -
Asynchronous Event Demultiplexer
:该函数阻塞等待完成的事件添加到完成队列中,并将其返回给调用方; -
Completion Event Queue
:完成事件队列,等待它们被多路分解到各自的完成处理程序; -
Asynchronous Operation
:表示长时间执行的异步操作; -
Asynchronous Operation Processor
:异步操作处理器,执行描述符的异步操作,生成相应的完成事件,并将其放入完成事件队列,一般由操作系统内核实现; -
Intiator
:应用程序本地的对象,用于启动异步操作,向异步操作处理器注册完成处理程序,启动Proactor主动器。
Proactor解决方案建议将每个应用程序服务划分为:
-
需要异步执行的长时间操作;
-
完成事件处理程序,用于处理关联的异步操作的结果,并可能调用其他异步操作;
Proactor执行序列图如下:
-
应用程序启动,调用异步操作处理器提供的异步操作接口函数执行异步操作。调用了这一步之后,应用程序和异步操作就分别独立运行了。应用程序可以继续调用新的异步操作,其他异步操作可以并发发生;
-
异步操作处理器发起真正的异步操作,等待异步操作事件的到来;
-
应用程序调用handle_events()启动Proactor主动器,进行Event Loop无限事件循环。等待事件的到来;
-
Proactor主动器进一步调用异步事件解复用器,等待事件的到来;
-
事件达到;
-
结果返回给异步操作处理器进行处理;
-
异步操作处理完之后把结果事件放入队列;
-
结果事件放入对接之后,触发通知异步事件解复用器;
-
解复用器反馈给Proactor主动器有完成事件;
-
Proactor主动器从完成事件队列中取出结果;
-
主动器拿到结果;
-
把结果分发到对应的完成事件处理器中;
-
完成事件处理器执行具体的处理逻辑。
更直观的流程如下:
-
1,2,3:
Initiator
启动程序分别创建Proactor
和Completion Handler
对象,通过AsyncOperationProcessor
将它们注册到内核中; -
4:调用handle_events()启动Proactor;客户端请求到服务端,服务端通过
AsyncOperationProcessor
注册请求事件,让其进行异步处理IO操作; -
5:AsyncOperationProcessor完成IO操作之后,通知Proactor;
-
6:Proactor根据响应的事件类型调用到不同的Handler中进行业务处理。
可以发现,大部分复杂的IO读写,完成事件触发等操作都在内核空间实现了,对于用户空间,重点关注:
-
需要异步执行的长时间操作;
-
完成事件处理程序。
2.5、Reactor与Proactor小结
Proactor主动处理IO
从名字就可以知道,Proactor为主动器模式,是属于主动处理IO:Proactor调用aio_write之后立刻返回,剩下的IO写就交由操作系统内核来处理了,无需Proactor亲自动手执行IO,处理完之后,Proactor再调用具体的完成写事件的回调函数处理后续的逻辑。
Proactor实现了一个主动的事件分离器和分发模型。
Reactor被动处理IO
Reactor反应堆模型为被动处理IO:Reactor直接把需要感知的IO事件注册到同步事件多路复用器中,等待事件的到来,然后由Reactor亲自动手,调用IO函数进行实际的IO处理。
可以用如下生动的比喻来形容他们:
Reactor可以理解为:事件来了我通知你,你来处理,如可以取快递了我就告诉你;
Proactor可以理解为:事件来了我来处理,处理完了再通知你,如我帮你取个快递,取到了告诉你;
为啥Proactor使用没有Reactor广泛?
虽然Proactor模式效率更高,通过使用异步IO,可以更加充分的发挥DMA(Direct Memory Access)的优势,但是,由于操作系统支持问题,导致了其并不能很好的被采用:虽然Windows实现了IOCP,但是Windows服务器应用范围小,而Linux系统对异步的支持有限,直到 Linux2.6才引入。所以大部分事件驱动框架还是通过IO复用来实现的;
Linux的aio操作不是真正的操作系统级别的支持,而是在用空间中借由GNU库函数由pthread方式实现的,没有对套接字IO进行支持。直到 Linux Kernel 5.1引入的io-uring才完成支持了异步IO。
关于高性能网络编程范式相关内容就介绍到这里了,在下一篇文章中我们会进一步分析常用的热门服务器程序的线程模型,探索他们高性能背后的故事,揭露他们不为人知的一面。
为了把相关系列文章收集起来,方便后续查阅,这里我创建了一个Github仓库,把发布的文章按照分类收集起来了,感兴趣的朋友可以Star跟进:
https://github.com/arthinking/java-tech-stack
References
[1]: http://www.kegel.com/c10k.html#top
[2]: Software Engineering Advice from Building Large-Scale Distributed Systems. Retrieved from http://static.googleusercontent.com/media/research.google.com/en/us/people/jeff/stanford-295-talk.pdf
[3]: 并发模型与事件循环. Retrieved from https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop
[4]: Web APIs. Retrieved from https://developer.mozilla.org/en-US/docs/Web/API.
[5]: NodeJS System diagram. Retrieved https://twitter.com/TotesRadRichard/status/494959181871316992
[6]: JavaScript 运行机制详解:再谈Event Loop. Retrieved from http://www.ruanyifeng.com/blog/2014/10/event-loop.html
[7]: Node.js 事件循环机制. Retrieved from https://www.cnblogs.com/onepixel/p/7143769.html
[8]: 事件驱动模型与观察者模型. Retrieved from https://www.itzhai.com//articles/event-driven-model-and-observer-pattern.html
[9]: Reactor and Proactor. Retrieved from http://didawiki.cli.di.unipi.it/lib/exe/fetch.php/magistraleinformatica/tdp/tpd_reactor_proactor.pdf