异步网络模型在服务开发中应用非常广泛,相关资料和开源库也非常多。项目中,使用现成的轮子提高了开发效率,除了能使用轮子,还是有必要了解一下轮子的内部构造。
这篇文章从最基础的5种I/O模型切入,到I/O事件处理模型,再到并发模式,最后以Swoole开源库来做具体分析,逐步深入。文中提到的模型都是一些通用的模型,在《linux高性能服务器编程》中也都有涉及。文章不涉及模型的实现细节,最重要的是去理解各个模型的工作模式以及其优缺点。
文中涉及接口调用的部分,都是指Linux系统的接口调用。 共分为5部分:
I/O模型
从基础的系统调用方法出发,给大家从头回顾一下最基本的I/O模型,虽然简单,但是不可或缺的基础;
事件处理模型
这部分在同步I/O、异步I/O的基础上分别介绍Reactor模型以及Proactor模型,着重两种模型的构成以及事件处理流程。Reactor模型是我们常见的;不同平台对异步I/O系统接口的支持力度不同,这部分还介绍了一种使用同步I/O来模拟Proactor模型的方法。
并发模式
就是多线程、多进程的编程的模式。介绍了两种较为高效的并发模型,半同步/半异步(包括其演变模式)、Follower/Leader模式。
Swoole异步网络模型分析
这部分是结合已介绍的事件处理模型、并发模式对Swoole的异步模型进行分析; 从分析的过程来看,看似复杂的网络模型,可以拆分为简单的模型单元,只不过我们需要权衡利弊,选取合适业务需求的模型单元进行组合。 我们团队基于Swoole 1.8.5版本,做了很多修改,部分模块做了重构,计划在17年6月底将修改后版本开源出去,敬请期待。
改善性能的方法
最后一部分是在引入话题,介绍的是几种常用的方法。性能优化是没有终点的,希望大家能贡献一些想法和具体方法。
POSIX 规范中定义了同步I/O 和异步I/O的术语,
同步I/O : 需要进程去真正的去操作I/O;
异步I/O:内核在I/O操作完成后再通知应用进程操作结果。
在《UNIX网络编程》中介绍了5中I/O模型:阻塞I/O、非阻塞I/O、I/O复用、SIGIO 、异步I/O;本节对这5种I/O模型进行说明和对比。
通常把阻塞的文件描述符(file descriptor,fd)称之为阻塞I/O。默认条件下,创建的socket fd是阻塞的,针对阻塞I/O调用系统接口,可能因为等待的事件没有到达而被系统挂起,直到等待的事件触发调用接口才返回,例如,tcp socket的connect调用会阻塞至第三次握手成功(不考虑socket 出错或系统中断),如图1所示。另外socket 的系统API ,如,accept、send、recv等都可能被阻塞。
图1 I/O 阻塞模型示意图
另外补充一点,网络编程中,通常把可能永远阻塞的系统API调用 称为慢系统调用,典型的如 accept、recv、select等。慢系统调用在阻塞期间可能被信号中断而返回错误,相应的errno 被设置为EINTR,我们需要处理这种错误,解决办法有:
1. 重启系统调用
直接上示例代码吧,以accept为例,被中断后重启accept 。有个例外,若connect 系统调用在阻塞时被中断,是不能直接重启的(与内核socket 的状态有关),有兴趣的同学可以深入研究一下connect 的内核实现。使用I/O复用等待连接完成,能避免connect不能重启的问题。
int client_fd = -1;
struct sockaddr_in client_addr;
socklen_t child_addrlen;
while (1) {
call_accept:
client_fd = accept(server_fd,NULL,NULL);
if (client_fd < 0) {
if (EINTR == errno) {
goto call_accept;
} else {
sw_sysError("accept fail");
break;
}
}
}
2. 信号处理
利用信号处理,可以选择忽略信号,或者在安装信号时设置SA_RESTART属性。设置属性SA_RESTART,信号处理函数返回后,被安装信号中断的系统调用将自动恢复,示例代码如下。需要知道的是,设置SA_RESTART属性方法并不完全适用,对某些系统调用可能无效,这里只是提供一种解决问题的思路,示例代码如下:
int client_fd = -1;
struct sigaction action,old_action;
action.sa_handler = sig_handler;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
action.sa_flags |= SA_RESTART;
/// 若信号已经被忽略,则不设置
sigaction(SIGALRM, NULL, &old_action);
if (old_action.sa_handler != SIG_IGN) {
sigaction(SIGALRM, &action, NULL);
}
while (1) {
client_fd = accept(server_fd,NULL,NULL);
if (client_fd < 0) {
sw_sysError("accept fail");
break;
}
}
把非阻塞的文件描述符称为非阻塞I/O。可以通过设置SOCK_NONBLOCK标记创建非阻塞的socket fd,或者使用fcntl将fd设置为非阻塞。
对非阻塞fd调用系统接口时,不需要等待事件发生而立即返回,事件没有发生,接口返回-1,此时需要通过errno的值来区分是否出错,有过网络编程的经验的应该都了解这点。不同的接口,立即返回时的errno值不尽相同,如,recv、send、accept errno通常被设置为EAGIN 或者EWOULDBLOCK,connect 则为EINPRO- GRESS 。
以recv操作非阻塞套接字为例,如图2所示。
图2 非阻塞I/O模型示意图
当我们需要读取,在有数据可读的事件触发时,再调用recv,避免应用层不断去轮询检查是否可读,提高程序的处理效率。通常非阻塞I/O与I/O事件处理机制结合使用。
最常用的I/O事件通知机制就是I/O复用(I/O multiplexing)。Linux 环境中使用select/poll/epoll 实现I/O复用,I/O复用接口本身是阻塞的,在应用程序中通过I/O复用接口向内核注册fd所关注的事件,当关注事件触发时,通过I/O复用接口的返回值通知到应用程序,如图3所示,以recv为例。I/O复用接口可以同时监听多个I/O事件以提高事件处理效率。
图 3 I/O复用模型示意图
关于select/poll/epoll的对比,可以参考[],epoll使用比较多,但是在并发的模式下,需要关注惊群的影响。
除了I/O复用方式通知I/O事件,还可以通过SIGIO信号来通知I/O事件,如图4所示。两者不同的是,在等待数据达到期间,I/O复用是会阻塞应用程序,而SIGIO方式是不会阻塞应用程序的。
图 4 信号驱动I/O模型示意图
POSIX规范定义了一组异步操作I/O的接口,不用关心fd 是阻塞还是非阻塞,异步I/O是由内核接管应用层对fd的I/O操作。异步I/O向应用层通知I/O操作完成的事件,这与前面介绍的I/O 复用模型、SIGIO模型通知事件就绪的方式明显不同。以aio_read 实现异步读取IO数据为例,如图5所示,在等待I/O操作完成期间,不会阻塞应用程序。
图 5 异步I/O 模型示意图
前面介绍的5中I/O中,I/O 阻塞、I/O非阻塞、I/O复用、SIGIO 都会在不同程度上阻塞应用程序,而只有异步I/O模型在整个操作期间都不会阻塞应用程序。
如图6所示,列出了5种I/O模型的比较
图6 五种I/O 模型比较示意图
网络设计模式中,如何处理各种I/O事件是其非常重要的一部分,Reactor 和Proactor两种事件处理模型应运而生。上章节提到将I/O分为同步I/O 和 异步I/O,可以使用同步I/O实现Reactor模型,使用异步I/O实现Proactor模型。
本章节将介绍Reactor和Proactor两种模型,最后将介绍一种使用同步I/O模拟Proactor事件处理模型。
Reactor模型是同步I/O事件处理的一种常见模型,关于Reactor模型结构的资料非常多,一个典型的Reactor模型类图结构如图7所示,
图 7 Reactor 模型类结构图
Reactor的核心思想:将关注的I/O事件注册到多路复用器上,一旦有I/O事件触发,将事件分发到事件处理器中,执行就绪I/O事件对应的处理函数中。模型中有三个重要的组件:
图7所示,Reactor 类结构中包含有如下角色。
a)供应用程序注册和删除关注的事件句柄;
b)运行事件处理循环;
c)等待的就绪事件触发,分发事件到之前注册的回调函数上处理.
接下来介绍Reactor的工作流程,如图8所示,为Reactor模型工作的简化流程。
图8 Reactor模型简化流程示意图
网络设计中,Reactor使用非常广,在开源社区有很许多非常成熟的、跨平台的、Reactor模型的网络库,比较典型如libevent。
与Reactor不同的是,Proactor使用异步I/O系统接口将I/O操作托管给操作系统,Proactor模型中分发处理异步I/O完成事件,并调用相应的事件处理接口来处理业务逻辑。Proactor类结构如图9所示。
图9 Proactor模型类结构图
图9所示,Proactor类结构中包含有如下角色:
Proactor模型的简化的工作流程,如图10所示。
图10 Proactor模型简化工作流程示意图
Proactor利用异步I/O并行能力,可给应用程序带来更高的效率,但是同时也增加了编程的复杂度。windows对异步I/O提供了非常好的支持,常用Proactor的模型实现服务器;而Linux对异步I/O操作(aio接口)的支持并不是特别理想,而且不能直接处理accept,因此Linux平台上还是以Reactor模型为主。
Boost asio采用的是Proactor模型,但是Linux上,采用I/O复用的方式来模拟Proactor,另启用线程来完成读写操作和调度。
下面一种使用同步I/O模拟Proactor的方案,原理是:
主线程执行数据读写操作,读写操作完成后,主线程向工作线程通知I/O操作“完成事件”;
工作流程如图 11所示。
图11 同步I/O模拟Proactor模型
简单的描述一下图11 的执行流程:
在I/O密集型的程序,采用并发方式可以提高CPU的使用率,可采用多进程和多线程两种方式实现并发。当前有高效的两种并发模式,半同步/半异步模式、Follower/Leader模式。
首先区分一个概念,并发模式中的“同步”、“异步”与 I/O模型中的“同步”、“异步”是两个不同的概念:
并发模式中,“同步”指程序按照代码顺序执行,“异步”指程序依赖事件驱动,如图12 所示并发模式的“同步”执行和“异步”执行的读操作;
I/O模型中,“同步”、“异步”用来区分I/O操作的方式,是主动通过I/O操作拿到结果,还是由内核异步的返回操作结果。
图12(a) 同步读操作示意图
图12(b) 异步读操作示意图
本节从最简单的半同步/半异步模式的工作流程出发,并结合事件处理模型介绍两种演变的模式。
半同步/半异步模式的工作流程如图13 所示。
图13 半同步/半异步模式的工作流程示意图
其中异步线程处理I/O事件,同步线程处理请求对象,简单的来说:
考虑将两种事件处理模型,即Reactor和Proactor,与几种I/O模型结合在一起,那么半同步/半异步模式就演变为半同步/半反应堆模式。先看看使用Reactor的方式,如图14 所示。
图14 半同步/半反应堆模式示意图
其工作流程为:
若将Reactor替换为Proactor,那么其工作流程为:
半同步/半反应堆模式有明显的缺点:
下面介绍一种改进的方式,如图15 所示,每个工作线程都有自己的事件循环,能同时独立处理多个用户连接。
图 15 半同步/半反应堆模式的演变模式
其工作流程为:
Follower/Leader是多个工作线程轮流进行事件监听、事件分发、处理事件的模式。
在Follower/Leader模式工作的任何一个时间点,只有一个工作线程处理成为Leader ,负责I/O事件监听,而其他线程都是Follower,并等待成为Leader。
Follower/Leader模式的工作流概述如下:
从上描述,Leader/Follower模式的工作线程存在三种状态,工作线程同一时间只能处于一种状态,这三种状态为:
Leader监听到I/O就绪事件后,有两种处理方式:
如图16所示为上面描述的三种状态的转移关系。
图16 Follower/Leader模式状态转移示意图
如图16所示,处于Processing状态的线程处理完I/O事件后,若当前不存在Leader,就自动提升为Leader,否则转变Follower。
从以上描述中可知,Follower/Leader模式中不需要在线程间传递数据,线程间也不存在共享资源。但很明显Follower/Leader 仅支持一个事件处理源集,无法做到图15所示的每个工作线程独立监听I/O事件。
Swoole为PHP提供I/O扩展功能,支持异步I/O、同步I/O、并发通信,并且为PHP多进程模式提供了并发数据结构和IPC通信机制;Swoole 既可以充当网络I/O服务器,也支持I/O客户端,较大程度为用户简化了网络I/O、多进程/多线程并发编程的工作。
Swoole作为server时,支持3种运行模式,分别是多进程模式、多线程模式、多进程+多线程模式;多进程+多线程模式是其中最为复杂的方式,其他两种方式可以认为是其特例。
本节结合之前介绍几种事件处理模型、并发模式来分析Swoole server的多进程+多线程模型,如图17。
图17 swoole server多进程+多线程模型结构示意图
图17所示,整体上可以分为Master Process、Manger Process、Work Process Pool三部分。这三部分的主要功能:
Manager Process 监控Work Process进程,本节不做进一步讲解,主要关注Master和Work。
Master Process 内部包括主线程(Main Thread)和工作线程池(Work Thread Pool),这两部分主要功能分别是:
主线程: 监听服务端口,接收网络连接,将成功建立的连接分发到线程池中;依赖reactor模型驱动;
工作线程池: 独立管理连接,收发网络数据;依赖Reactor事件处理驱动。
顾一下前面介绍的半同步/半异步并发模式,很明显,主进程的工作方式就是图15所示的方式。
如上所描述,Work Process是Master Process和PHP层之间的媒介:
Work Process同样是依赖Reactor事件模型驱动,其工作方式一个典型的Reactor模式。
Work Process作为Master Process和PHP层之间的媒介,将数据收发操作和数据处理分离开来,即使PHP层因消息处理将Work进程阻塞一段时间,也不会对其他连接有影响。
从整体层面来看,Master Process实现对连接socket上数据的I/O操作,这个过程对于Work Process是异步的,结合图11 所描述的同步I/O模拟Proactor模式,两种方式如出一辙,只不过这里使用的是多进程。
Work Process是Master Process和PHP层之间的媒介,那么需要看看Work Process 与Master Process之间的通信方式,并在Swoole server 的多进程+多线程模型进程中,整个过程还是有些复杂,下面说明一下该流程,如图18所示。
图18 swoole server 多进程多线程通信示意图
具体流程为:
需要注意在步骤5中,Work进程通知Master进程有数据需要发送,不是将数据直接发送给Master进程,而是将数据地址(在共享内存中)发送给Master进程。
性能对于服务器而言是非常敏感和重要的,当前,硬件的发展虽然不是服务器性能的瓶颈,作为软件开发人员还是应该考虑在软件层面来上改善服务性能。好的网络模块,除了稳定性,还有非常多的细节、技巧处理来提升服务性能,感兴趣的同学可以深入了解Ngnix源码的细节,以及陈硕的《Linux多线程服务器编程》。
如果应用程序不关心数据的内容,就没有必要将数据拷贝到应用缓冲区,可以借助内核接口直接将数据拷贝到内核缓冲区处理,如在提供文件下载服务时,不需要将文件内容先读到应用缓冲区,在调用send接口发送出去,可以直接使用sendfile (零拷贝)接口直接发送出去。
应用程序的工作模块之间也应该避免数据拷贝,如:
在服务运行期间,需要使用系统调用为用户分配资源,通常系统资源的分配都是比较耗时的,如动态创建进程/线程。可以考虑在服务启动时预先分配资源,即创建资源池,当需要资源,从资源池中获取即可,若资源池不够用时,再动态的分配,使用完成后交还到资源池中。这实际上是用空间换取时间,在服务运行期间可以节省非必要的资源创建过程。需要注意的是,使用资源池还需要根据业务和硬件环境对资源池的大小进行限制。
资源池是一个抽象的概念,常见的包括进程池、线程池、 内存池、连接池;这些资源池的相关资料非常多,这里就不一一介绍了。
1.关于锁
对共享资源的操作是并发程序中经常被提起的一个话题,都知道在业务逻辑上无法保证同步操作共享资源时,需要对共享资源加锁保护,但是锁不仅不能处理任何业务逻辑,而且还存在一定的系统开销。并且对锁的不恰当使用,可能成为服务期性能的瓶颈。
针对锁的使用有如下建议:
2.上下文切换
并发程序需要考虑上下文切换的问题,内核调度线程(进程)执行是存在系统开销的,若线程(进程)调度占用CPU的时间比重过大,那处理业务逻辑占用的CPU时间就会不足。在项目中,线程(进程)数量越多,上下文切换会很频繁,因此是不建议为每个用户连接创建一个线程,如图15所示的并发模式,一个线程可同时处理多个用户连接,是比较合理的解决方案。
多核的机器上,并发程序的不同线程可以运行在不同的CPU上,只要线程数量不大于CPU数目,上下文切换不会有什么问题,在实际的并发网络模块中,线程(进程)的个数也是根据CPU数目来确定的。在多核机器上,可以设置CPU亲和性,将进程/线程与CPU绑定,提高CPU cache的命中率,建好内存访问损耗。
有限状态机是一种高效的逻辑处理方式,在网络协议处理中应用非常广泛,最典型的是内核协议栈中TCP状态转移。有限状态机中每种类型对应执行逻辑单元的状态,对逻辑事务的处理非常有效。 有限状态机包括两种,一种是每个状态都是相互独立的,状态间不存在转移;另一种就是状态间存在转移。有限状态机比较容易理解,下面给出两种有限状态机的示例代码。
不存在状态转移
typedef enum _tag_state_enum{
A_STATE,
B_STATE,
C_STATE,
D_STATE
}state_enum;
void STATE_MACHINE_HANDLER(state_enum cur_state) {
switch (cur_state){
case A_STATE:
process_A_STATE();
break;
case B_STATE:
process_B_STATE();
break;
case C_STATE:
process_C_STATE();
break;
default:
break;
}
return ;
}
存在状态转移
void TRANS_STATE_MACHINE_HANDLER(state_enum cur_state) {
while (C_STATE != cur_state) {
switch (cur_state) {
case A_STATE:
process_A_STATE();
cur_state = B_STATE;
break;
case B_STATE:
process_B_STATE();
cur_state = C_STATE;
break;
case C_STATE:
process_C_STATE();
cur_state = D_STATE;
break;
default:
return ;
}
}
return ;
}
经常会面临一些业务定时超时的需求,用例子来说明吧。
功能需求:服务器需要维护来自大量客户端的TCP连接(假设单机服务器需要支持的最大TCP连接数在10W级别),如果某连接上60s内没有数据到达,就认为相应的客户端下线。
先介绍一下两种容易想到的解决方案,
方案a 轮询扫描
处理过程为:
轮询扫描,只启动一个定时器,但轮询效率低,特别是服务器维护的连接数很大时,部分连接超时事件得不到及时处理。
方案b 多定时器触发
处理过程为:
多定时器触发,每次请求都要启动一个定时器,可以想象,消息请求非常频繁是,定时器的数量将会很庞大,消耗大量的系统资源。
方案c 时间轮方案
下面介绍一下利用时间轮的方式实现的一种高效、能批量的处理方案,先说一下需要的数据结构:
图19 时间轮环形队列示意图
执行过程为:
与a、b两种方案相比,方案c具有如下优势:
上面描述的时间轮处理方式会存在1s以内的误差,若考虑实时性,可以提高定时器的运行间隔,另外该方案可以根据实际业务需求扩展到应用中。我们对Swoole的修改中,包括对定时器进行了重构,其中超时定时器采用的就是如上所描述的时间轮方案,并且精度可控。
欢迎关注我们的公众号
Original url: Access
Created at: 2019-09-26 17:22:04
Category: default
Tags: none
未标明原创文章均为采集,版权归作者所有,转载无需和我联系,请注明原出处,南摩阿彌陀佛,知识,不只知道,要得到
最新评论