多线程并发存在两个主要问题:一是容易出现死锁等并发缺陷,二是开发者无法控制调度。为了解决这些问题并显式控制调度,我们可以采用基于事件的并发(Event-based Concurrency)。
基本概念:事件循环 (Event Loop)
基于事件的并发核心是事件循环:等待事件发生,检查事件类型,并执行相应的少量工作(事件处理程序)。
while (1) {
events = getEvents();
for (e in events)
processEvent(e);
}其最大优势在于对调度的显式控制。由于一次只处理一个事件(单线程),因此不需要获取或释放锁,避免了多线程程序中常见的并发缺陷。
接收事件:select() 与 poll()
为了确定哪些 I/O 事件已准备就绪,大多数系统提供了 select() 或 poll() 系统调用。
int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);select() 检查给定的 I/O 描述符集合(读取、写入、异常),并返回就绪描述符的总数。通过设置 timeout 参数,可以控制调用是阻塞(NULL)还是立即返回(0)。
使用 select() 的简单示例
通过 FD_ZERO 初始化集合,FD_SET 添加描述符,调用 select() 后使用 FD_ISSET 检查就绪状态:
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
// 初始化套接字...
while (1) {
fd_set readFDs;
FD_ZERO(&readFDs);
int fd;
for (fd = minFD; fd < maxFD; fd++)
FD_SET(fd, &readFDs);
int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
for (fd = minFD; fd < maxFD; fd++)
if (FD_ISSET(fd, &readFDs))
processFD(fd);
}
}阻塞调用的挑战与异步 I/O
基于事件的系统有一个严格规则:不允许阻塞调用。如果事件处理程序发出阻塞调用(如读取磁盘文件),整个事件循环将被挂起,导致系统资源浪费。
解决方案:异步 I/O (AIO)
现代操作系统提供了异步 I/O 接口,允许应用程序发出 I/O 请求后立即返回。以 macOS 为例,使用 struct aiocb (AIO 控制块) 和 aio_read() 发起异步读取:
struct aiocb {
int aio_fildes; /* 文件描述符 */
off_t aio_offset; /* 文件偏移量 */
volatile void *aio_buf; /* 缓冲区位置 */
size_t aio_nbytes; /* 传输长度 */
};
int aio_read(struct aiocb *aiocbp);为了得知 I/O 何时完成,可以使用 aio_error() 定期轮询:
int aio_error(const struct aiocb *aiocbp);由于轮询效率低下,系统还提供了基于中断 (Interrupt) 的方法,利用 UNIX 信号(Signal)在异步 I/O 完成时通知应用程序,从而避免重复检查。
状态管理与延续 (Continuation)
基于事件的代码通常比多线程代码更复杂,因为需要进行手工栈管理 (Manual Stack Management)。
在多线程中,I/O 完成后的状态保存在线程栈中。而在事件驱动系统中,事件处理程序发出异步 I/O 后,必须将程序状态打包保存。当 I/O 完成时,下一个事件处理程序需要使用这些状态。
解决方案是使用延续 (Continuation):在数据结构(如散列表)中记录处理该事件所需的信息。例如,将网络套接字描述符以文件描述符为索引记录下来,当磁盘 I/O 完成时,通过文件描述符查找对应的套接字描述符,完成后续的数据写入。
依然存在的难题
尽管基于事件的并发解决了部分问题,但仍面临以下挑战:
- 多核并发:在多 CPU 系统上并行运行多个事件处理程序时,依然需要引入锁等同步机制,丧失了单线程的简单性。
- 隐式阻塞:如发生页错误 (Page Fault),事件处理程序会被隐式阻塞,严重影响性能且难以避免。
- API 语义变化:如果系统 API 从非阻塞变为阻塞,事件处理程序必须重构。
- 统一接口缺失:异步磁盘 I/O 与异步网络 I/O 的集成不够统一(通常需要混合使用
select()和 AIO)。