异步网络模型
#异步网络模型
异步网络模型在服务开发中应用非常广泛,相关资料和开源库也非常多。项目中,使用现成的轮子提高了开发效率,除了能使用轮子,还是有必要了解一下轮子的内部构造。
这篇文章从最基础的 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 月底将修改后版本开源出去,敬请期待。
改善性能的方法
最后一部分是在引入话题,介绍的是几种常用的方法。性能优化是没有终点的,希望大家能贡献一些想法和具体方法。
I/O 模型
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 模型进行说明和对比。
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 非阻塞
把非阻塞的文件描述符称为非阻塞 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 复用 (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 使用比较多,但是在并发的模式下,需要关注惊群的影响。
SIGIO
除了 I/O 复用方式通知 I/O 事件,还可以通过 SIGIO 信号来通知 I/O 事件,如图 4 所示。两者不同的是,在等待数据达到期间,I/O 复用是会阻塞应用程序,而 SIGIO 方式是不会阻塞应用程序的。
图 4 信号驱动I/O模型示意图
异步 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 模型示意图
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 事件处理模型
Reactor 模型是同步 I/O 事件处理的一种常见模型,关于 Reactor 模型结构的资料非常多,一个典型的 Reactor 模型类图结构如图 7 所示,
图 7 Reactor 模型类结构图
Reactor 的核心思想:将关注的 I/O 事件注册到多路复用器上,一旦有 I/O 事件触发,将事件分发到事件处理器中,执行就绪 I/O 事件对应的处理函数中。模型中有三个重要的组件:
- 多路复用器:由操作系统提供接口,Linux 提供的 I/O 复用接口有 select、poll、epoll;
- 事件分离器:将多路复用器返回的就绪事件分发到事件处理器中;
- 事件处理器:处理就绪事件处理函数。
图 7 所示,Reactor 类结构中包含有如下角色。
- Handle:标示文件描述符;
- Event Demultiplexer:执行多路事件分解操作,对操作系统内核实现 I/O 复用接口的封装;用于阻塞等待发生在句柄集合上的一个或多个事件(如 select/poll/epoll);
- Event Handler:事件处理接口;
- Event Handler A(B):实现应用程序所提供的特定事件处理逻辑;
- Reactor:反应器,定义一个接口,实现以下功能:
a)供应用程序注册和删除关注的事件句柄;
b)运行事件处理循环;
c)等待的就绪事件触发,分发事件到之前注册的回调函数上处理.
接下来介绍 Reactor 的工作流程,如图 8 所示,为 Reactor 模型工作的简化流程。
图8 Reactor模型简化流程示意图
- 注册 I/O 就绪事件处理器;
- 事件分离器等待 I/O 就绪事件;
- I/O 事件触发,激活事件分离器,分离器调度对应的事件处理器;
- 事件处理器完成 I/O 操作,处理数据.
网络设计中,Reactor 使用非常广,在开源社区有很许多非常成熟的、跨平台的、Reactor 模型的网络库,比较典型如 libevent。
Proactor 事件处理模型
与 Reactor 不同的是,Proactor 使用异步 I/O 系统接口将 I/O 操作托管给操作系统,Proactor 模型中分发处理异步 I/O 完成事件,并调用相应的事件处理接口来处理业务逻辑。Proactor 类结构如图 9 所示。
图9 Proactor模型类结构图
图 9 所示,Proactor 类结构中包含有如下角色:
- Handle: 用来标识 socket 连接或是打开文件;
- Async Operation Processor:异步操作处理器;负责执行异步操作,一般由操作系统内核实现;
- Async Operation:异步操作;
- Completion Event Queue:完成事件队列;异步操作完成的结果放到队列中等待后续使用;
- Proactor:主动器;为应用程序进程提供事件循环;从完成事件队列中取出异步操作的结果,分发调用相应的后续处理逻辑;
- Completion Handler:完成事件接口;一般是由回调函数组成的接口;
- Completion Handler A(B):完成事件处理逻辑;实现接口定义特定的应用处理逻辑。
Proactor 模型的简化的工作流程, 如图 10 所示。
图10 Proactor模型简化工作流程示意图
- 发起 I/O 异步操作,注册 I/O 完成事件处理器;
- 事件分离器等待 I/O 操作完成事件;
- 内核并行执行实际的 I/O 操作,并将结果数据存入用户自定义缓 冲区;
- 内核完成 I/O 操作,通知事件分离器,事件分离器调度对应的事件处理器;
- 事件处理器处理用户自定义缓冲区中的数据。
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 模拟 Proactor 的方案,原理是:
主线程执行数据读写操作,读写操作完成后,主线程向工作线程通知 I/O 操作“完成事件”;
工作流程如图 11 所示。
图11 同步I/O模拟Proactor模型
简单的描述一下图 11 的执行流程:
- 主线程往系统 I/O 复用中注册文件描述符 fd 上的读就绪事件;
- 主线程调用调用系统 I/O 复用接口等待文件描述符 fd 上有数据可读;
- 当 fd 上有数据可读时,通知主线程。主线程循环读取 fd 上的数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后向 I/O 复用中注册 fd 上的写就绪事件。主线程进入事件等待循环,等待 fd 可写。
并发模式
在 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 半同步/半反应堆模式示意图
其工作流程为:
- 异步线程监听所有 fd 上的 I/O 事件,若监听 socket 接可读,接受新的连接;并监听该连接上的读写事件;
- 若连接 socket 上有读写事件发生,异步线程将该连接 socket 插入请求队列中;
- 同步线程被唤醒,并接管连接 socket,从 socket 上读取请求和发送应答;
若将 Reactor 替换为 Proactor,那么其工作流程为:
- 异步线程完成 I/O 操作,并 I/O 操作的结果封装为任务对象,插入请求队列中;
- 请求队列通知同步线程处理任务;
- 同步线程执行任务处理逻辑。
一种高效的演变模式
半同步/半反应堆模式有明显的缺点:
- 异步线程和同步线程共享队列,需要保护,存在资源竞争;
- 工作线程同一时间只能处理一个任务,任务处理量很大或者任务处理存在一定的阻塞时,任务队列将会堆积,任务的时效性也等不到保证;不能简单地考虑增加工作线程来处理该问题,线程数达到一定的程度,工作线程的切换也将白白消耗大量的 CPU 资源。
下面介绍一种改进的方式,如图 15 所示,每个工作线程都有自己的事件循环,能同时独立处理多个用户连接。
图 15 半同步/半反应堆模式的演变模式
其工作流程为:
- 主线程实现连接监听,只处理网络 I/O 连接事件;
- 新的连接 socket 分发至工作线程中,这个 socket 上的 I/O 事件都由该工作线程处理,工作线程都可以处理多个 socket 的 I/O 事件;
- 工作线程独立维护自己的事件循环,监听不同连接 socket 的 I/O 事件。
Follower/Leader 模式
Follower/Leader 是多个工作线程轮流进行事件监听、事件分发、处理事件的模式。
在 Follower/Leader 模式工作的任何一个时间点,只有一个工作线程处理成为 Leader ,负责 I/O 事件监听,而其他线程都是 Follower,并等待成为 Leader。
Follower/Leader 模式的工作流概述如下:
- 当前 Leader Thread1 监听到就绪事件后,从 Follower 线程集中推选出 Thread 2 成为新的 Leader;
- 新的 Leader Thread2 继续事件 I/O 监听;
- Thread1 继续处理 I/O 就绪事件,执行完后加入到 Follower 线程集中,等待成为 Leader。
从上描述,Leader/Follower 模式的工作线程存在三种状态,工作线程同一时间只能处于一种状态,这三种状态为:
-
Leader:线程处于领导者状态,负责监听 I/O 事件;
-
Processing:线程处理就绪 I/O 事件;
-
Follower:等待成为新的领导者或者可能被当前 Leader 指定处理就绪事件。
Leader 监听到 I/O 就绪事件后,有两种处理方式:
- 推选出新的 Leader 后,并转移到 Processing 处理该 I/O 就绪事件;
- 指定其他 Follower 线程处理该 I/O 就绪事件,此时保持 Leader 状态不变;
如图 16 所示为上面描述的三种状态的转移关系。
图16 Follower/Leader模式状态转移示意图
如图 16 所示,处于 Processing 状态的线程处理完 I/O 事件后,若当前不存在 Leader,就自动提升为 Leader,否则转变 Follower。
从以上描述中可知,Follower/Leader 模式中不需要在线程间传递数据,线程间也不存在共享资源。但很明显 Follower/Leader 仅支持一个事件处理源集,无法做到图 15 所示的每个工作线程独立监听 I/O 事件。
Swoole 网络模型分析
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 三部分。这三部分的主要功能:
- **Master Process:** 监听服务端口,接收用户连接,收发连接数据,依靠 reactor 模型驱动;
- **Manager Process:**Master Process 的子进程,负责 fork WorkProcess,并监控 Work Process 的运行状态;
- **Work Process Pool:** 工作进程池,与 PHP 业务层交互,将客户端数据或者事件(如连接关闭)回调给业务层,并将业务层的响应数据或者操作(如主动关闭连接)交给 Master Process 处理;工作进程依靠 reactor 模型驱动。
Manager Process 监控 Work Process 进程,本节不做进一步讲解,主要关注 Master 和 Work。
Master Process
Master Process 内部包括主线程 (Main Thread) 和工作线程池(Work Thread Pool), 这两部分主要功能分别是:
主线程: 监听服务端口,接收网络连接,将成功建立的连接分发到线程池中;依赖 reactor 模型驱动;
工作线程池: 独立管理连接,收发网络数据;依赖 Reactor 事件处理驱动。
顾一下前面介绍的半同步 / 半异步并发模式,很明显,主进程的工作方式就是图 15 所示的方式。
Work Process
如上所描述,Work Process 是 Master Process 和 PHP 层之间的媒介:
- Work Process 接收来自 Master Process 的数据,包括网络数据和连接事件,回调至 PHP 业务层;
- 将来自 PHP 层的数据和连接控制信息发送给 Master Process 进程,Master Process 来处理。
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 多进程多线程通信示意图
具体流程为:
- Master 进程主线程接收客户端连接,连接建立成功后,分发至工作线程,工作线程通过 Unix Socket 通知 Work 进程连接信息;
- Work 进程将连接信息回调至 PHP 业务层;
- Maser 进程中的工作线程接收客户端请求消息,并通过 Unix Socket 方式发送到 Work 进程;
- Work 进程将请求消息回调至 PHP 业务层;
- PHP 业务层构造回复消息,通过 Work 进程发送,Work 进程将回复消息拷贝至共享内存中,并通过 Unix Socket 通知发送至 Master 进程的工作线程有数据需要发送;
- 工作线程从共享内存中取出需发送的数据,并发送至客户端;
- 客户端断开连接,工作线程将连接断开的事件通过 UnixSocket 发送至 Work 进程;
- Work 进程将连接断开事件回调至 PHP 业务层.
需要注意在步骤 5 中,Work 进程通知 Master 进程有数据需要发送,不是将数据直接发送给 Master 进程,而是将数据地址 (在共享内存中) 发送给 Master 进程。
改善性能的方法
性能对于服务器而言是非常敏感和重要的,当前,硬件的发展虽然不是服务器性能的瓶颈,作为软件开发人员还是应该考虑在软件层面来上改善服务性能。好的网络模块,除了稳定性,还有非常多的细节、技巧处理来提升服务性能,感兴趣的同学可以深入了解 Ngnix 源码的细节,以及陈硕的《Linux 多线程服务器编程》。
数据复制
如果应用程序不关心数据的内容,就没有必要将数据拷贝到应用缓冲区,可以借助内核接口直接将数据拷贝到内核缓冲区处理,如在提供文件下载服务时,不需要将文件内容先读到应用缓冲区,在调用 send 接口发送出去,可以直接使用 sendfile (零拷贝)接口直接发送出去。
应用程序的工作模块之间也应该避免数据拷贝,如:
- 当两个工作进程之间需要传递数据,可以考虑使用共享内存的方式实现数据共享;
- 在流媒体的应用中,对帧数据的非必要拷贝会对程序性能的影响,特备是在嵌入式环境中影响非常明显。通常采用的办法是,给每帧数据分配内存(下面统称为 buffer),当需要使用该 buffer 时,会增加该 buffer 的引用计数,buffer 的引用计数为 0 时才会释放对应的内存。这种方式适合在进程内数据无拷贝传递,并且不会给释放 buffer 带来困扰。
资源池
在服务运行期间,需要使用系统调用为用户分配资源,通常系统资源的分配都是比较耗时的,如动态创建进程/线程。可以考虑在服务启动时预先分配资源,即创建资源池,当需要资源,从资源池中获取即可,若资源池不够用时,再动态的分配,使用完成后交还到资源池中。这实际上是用空间换取时间,在服务运行期间可以节省非必要的资源创建过程。需要注意的是,使用资源池还需要根据业务和硬件环境对资源池的大小进行限制。
资源池是一个抽象的概念,常见的包括进程池、线程池、 内存池、连接池;这些资源池的相关资料非常多,这里就不一一介绍了。
锁 / 上下文切换
1.关于锁
对共享资源的操作是并发程序中经常被提起的一个话题,都知道在业务逻辑上无法保证同步操作共享资源时,需要对共享资源加锁保护,但是锁不仅不能处理任何业务逻辑,而且还存在一定的系统开销。并且对锁的不恰当使用,可能成为服务期性能的瓶颈。
针对锁的使用有如下建议:
- 如果能够在设计层面避免共享资源竞争,就可以避免锁,如图 15 描述的模式;
- 若无法避免对共享资源的竞争,优先考虑使用无锁队列的方式实现共享资源;
- 使用锁时,优先考虑使用读写锁;此外,锁的范围也要考虑,尽量较少锁的颗粒度,避免其他线程无谓的等待。
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 轮询扫描
处理过程为:
- 维护一个 map<client_id, last_update_time > 记录客户端最近一次的请求时间;
- 当 client_id 对应连接有数据到达时,更新 last_update_time;
- 启动一个定时器,轮询扫描 map 中 client_id 对应的 last_update_time,若超过 60s,则认为对应的客户端下线。
轮询扫描,只启动一个定时器,但轮询效率低,特别是服务器维护的连接数很大时,部分连接超时事件得不到及时处理。
方案 b 多定时器触发
处理过程为:
- 维护一个 map<client_id, last_update_time > 记录客户端最近一次的请求时间;
- 当某 client_id 对应连接有数据到达时,更新 last_update_time,同时为 client_id 启用一个定时器,60s 后触发;
- 当 client_id 对应的定时器触发后,查看 map 中 client_id 对应的 last_update_time 是否超过 60s,若超时则认为对应客户端下线。
多定时器触发,每次请求都要启动一个定时器,可以想象,消息请求非常频繁是,定时器的数量将会很庞大,消耗大量的系统资源。
方案 c 时间轮方案
下面介绍一下利用时间轮的方式实现的一种高效、能批量的处理方案,先说一下需要的数据结构:
- 创建 0~60 的数据,构成环形队列 time_wheel,current_index 维护环形队列的当前游标,如图 19 所示;
- 数组元素是 slot 结构,slot 是一个 set<client_id>,构成任务集;
- 维护一个 map<client_id,index>,记录 client_id 落在哪个 slot 上。
图19 时间轮环形队列示意图
执行过程为:
- 启用一个定时器,运行间隔 1s,更新 current_index,指向环形队列下一个元素,0->1->2->3…->58->59->60…0;
- 连接上数据到达时,从 map 中获取 client_id 所在的 slot,在 slot 的 set 中删除该 client_id;
- 将 client_id 加入到 current_index - 1 锁标记的 slot 中;
- 更新 map 中 client_id 为 current_id-1 。
与 a、b 两种方案相比,方案 c 具有如下优势:
- 只需要一个定时器,运行间隔 1s,CPU 消耗非常少;
- current_index 所标记的 slot 中的 set 不为空时,set 中的所有 client_id 对应的客户端均认为下线,即批量超时。
上面描述的时间轮处理方式会存在 1s 以内的误差,若考虑实时性,可以提高定时器的运行间隔,另外该方案可以根据实际业务需求扩展到应用中。我们对 Swoole 的修改中,包括对定时器进行了重构,其中超时定时器采用的就是如上所描述的时间轮方案,并且精度可控。