网络 IO 模型简介
参考
IO多路复用——深入浅出理解select、poll、epoll的实现
小林Coding 9.2 I/O 多路复用:select/poll/epoll
1. I/O 两阶段阻塞
第一阶段阻塞:等待数据准备
- 动作: 用户进程调用
read
(或其他 I/O 系统调用,如recvfrom
)。 - 阻塞原因: 此时,内核发现它没有用户请求的数据。例如,网络数据包可能还没有到达网卡,或者即使到达了,也还没有被 CPU 拷贝到内核的接收缓冲区中。
- 结果: 用户进程会从运行状态变为阻塞状态,被挂起,等待操作系统把数据准备好。它会一直等待,直到数据从网络到达并被内核放置到某个内核缓冲区中。在这个阶段,进程不消耗 CPU 时间,而是等待事件发生。
第二阶段阻塞:等待数据从内核复制到用户空间
- 动作: 经过第一阶段的等待,内核已经成功地将数据从网络硬件(例如网卡)接收并存储到了内核的接收缓冲区中。
- 阻塞原因: 尽管数据已经存在于内核空间,但它还没有被拷贝到用户进程指定的用户缓冲区中。操作系统需要进行一次内存拷贝操作,将数据从内核缓冲区传输到用户进程的地址空间。
- 结果: 用户进程在数据拷贝完成之前仍然处于阻塞状态。只有当数据完全复制到用户缓冲区后,
read
系统调用才会返回,用户进程才能解除阻塞,继续执行后续代码。
只有异步 I/O 在第二阶段不是阻塞的。
2. 网络 I/O 模型概述
网络 I/O 模型通常指的是在 Unix/Linux 系统下,进程如何与内核进行网络 I/O 操作的方式。它们分别是以下五种:
- Blocking I/O
- Non-blocking I/O
- Signal Driven I/O
- I/O Multiplexing, (Contains select, poll, epoll and so on)
- Asynchronous I/O
模型 | 数据准备阶段 | 数据拷贝阶段 | 进程状态 | 效率 / 复杂性 | 典型应用场景 |
---|---|---|---|---|---|
阻塞 I/O | 阻塞 | 阻塞 | 阻塞 | 简单 / 低效 | 单连接,请求处理长 |
非阻塞 I/O | 非阻塞 (EAGAIN) | 阻塞 | 轮询 (忙等) | 复杂 / CPU 占用高 | 不常用,基础模型 |
I/O 复用 | 阻塞 (在 select/poll/epoll 上) |
阻塞 | 阻塞 (在 select/poll/epoll 上) |
较复杂 / 高效 | 高并发服务器 (Web, Chat) |
信号驱动 I/O | 非阻塞 | 阻塞 | 非阻塞 (信号通知) | 较复杂 / 较高效 | 实时性要求高,连接数适中 |
异步 I/O | 非阻塞 | 非阻塞 | 非阻塞 | 最复杂 / 最高效 | 高性能网络编程,事件驱动 |
在实际的网络编程中,I/O 复用 模型是最常用也是最推荐的。特别是 epoll
,由于其高效的事件通知机制,是 Linux 下构建高性能并发网络服务器的首选。阻塞 I/O 适用于简单或并发量不高的场景,此时结合多线程或多进程可以有效改善阻塞 I/O 的性能。而非阻塞 I/O 和信号驱动 I/O 则较少直接使用,它们的概念更多地被其他模型吸收或作为底层实现。异步 I/O (POSIX I/O) 虽然理论上最强大,但其复杂性和一些平台限制使得它不如 I/O 复用普及。
2.1 Blocking I/O
这是最简单也是最常见的 I/O 模型。当用户进程调用一个 I/O 操作(如 recvfrom
)时,系统会从 用户空间切换到内核空间。如果数据尚未准备好,调用进程会被 阻塞,直到数据准备好并从内核缓冲区复制到用户空间,或者发生错误。在数据复制完成之前,用户进程无法进行任何其他操作。
特点:
- 简单易用: 编程模型最简单。
- 资源浪费: 在等待 I/O 完成的过程中,进程被阻塞,不能做其他事情,CPU 利用率低。
- 适用场景: 连接数较少,且每个连接的请求处理时间较长的情况。
流程:
- 用户进程调用
recvfrom
。 - 内核开始准备数据。
- 数据未准备好时,进程阻塞。
- 数据准备好,内核将数据从网络复制到内核缓冲区。
- 内核将数据从内核缓冲区复制到用户缓冲区。
recvfrom
调用返回,进程解除阻塞。
2.2 Non-blocking I/O
当用户进程调用一个 I/O 操作时,如果数据尚未准备好,系统会立即返回一个错误(例如 EWOULDBLOCK
或 EAGAIN
),而不是阻塞进程。用户进程可以继续执行其他任务,然后通过轮询的方式(重复调用 I/O 操作)来检查数据是否准备好。
在大多数现代 Unix/Linux 系统中,
EWOULDBLOCK
和EAGAIN
的数值是相同的,它们是等价的。// error.h #define EWOULDBLOCK EAGAIN /* OperatI/On would block */ // error_base.h #define EAGAIN 11 /* Try again */
它们都表示非阻塞操作无法立即完成,你需要稍后重试。这使得调用进程不会被阻塞,可以继续执行其他任务。
这两种名称的出现,通常是为了在语义上区分导致操作无法立即完成的两种常见情况:
EWOULDBLOCK
(Operation would block,操作会阻塞)
- 这个名称更侧重于表示:如果当前操作以阻塞模式执行,它会阻塞调用进程。但因为文件描述符被设置为了非阻塞模式,所以系统选择不阻塞,而是返回错误码。
- 常见场景: 当你尝试从一个非阻塞的 socket 读取数据,但目前没有数据可读时,或者尝试写入数据但发送缓冲区已满时,通常会返回这个错误。它提醒你,如果不是非阻塞模式,你就会被“堵”住。
EAGAIN
(Resource temporarily unavailable / 资源暂时不可用)
- 这个名称更侧重于表示:请求的资源(例如:输入数据,或者输出缓冲区空间)暂时不可用。它暗示你可以“稍后再次尝试”(try again)。
- 常见场景: 除了 I/O 操作外,
EAGAIN
也可能在其他需要资源的系统调用中出现,例如fork()
创建子进程时,如果系统没有足够的内存或进程槽位,也可能返回EAGAIN
。在网络 I/O 中,它与EWOULDBLOCK
的作用基本一致。但在实践中,特别是在网络编程中,你可以将它们视为同一个错误,通常会写成
if (errno == EWOULDBLOCK || errno == EAGAIN)
来处理。
特点:
- 不会阻塞: 进程不会被阻塞,可以继续执行其他任务。
- CPU 占用高: 需要用户进程频繁地轮询检查 I/O 状态,如果轮询频率过高,会消耗大量 CPU 资源。
- 编程复杂: 需要自己管理轮询逻辑。
流程:
- 用户进程调用
recvfrom
。 - 内核开始准备数据。
- 数据未准备好,立即返回
EAGAIN
,进程不阻塞。 - 用户进程循环调用
recvfrom
,直到数据准备好。 - 数据准备好,内核将数据复制到用户缓冲区。
recvfrom
调用返回,进程获取数据。
2.3 I/O Multiplexing
也称为 事件驱动 I/O 或多路转接 I/O。这种模型允许一个进程同时监听多个文件描述符(sockets),一旦某个文件描述符的数据准备好或有事件发生,就通知应用程序。最常见的实现包括 select
、poll
和 epoll
。
特点:
- 单线程处理多连接: 一个线程可以同时管理多个连接的 I/O。
- 效率高: 只有当有 I/O 事件发生时才进行处理,避免了轮询的 CPU 浪费。
- 编程复杂: 相对于阻塞 I/O 而言,编程模型更复杂。
- 适用场景: 大量并发连接(例如聊天服务器、Web 服务器)。
主要实现:
select
: 监听文件描述符集合,有数量限制(通常是 1024)。每次调用都需要将整个文件描述符集合从用户空间复制到内核空间,效率较低。poll
: 类似于select
,但没有文件描述符数量限制,使用链表而非位图来存储文件描述符。epoll
(Linux 独有): 是 Linux 下最高效的 I/O 复用机制。它通过事件通知而非轮询的方式工作,并且不限制文件描述符数量。epoll
提供了两种工作模式:LT (Level Triggered) 和 ET (Edge Triggered)。
流程(以 select
为例):
- 用户进程调用
select
,并将感兴趣的文件描述符集合传递给内核。 - 进程阻塞,等待有 I/O 事件发生。
- 当一个或多个文件描述符的数据准备好时,内核通知进程,并返回就绪的文件描述符数量。
- 进程解除阻塞,遍历文件描述符集合,找到就绪的描述符。
- 对就绪的描述符执行阻塞 I/O 操作(如
read
)。
2.4 Signal Driven I/O
这种模型允许进程向内核注册一个 信号处理函数,当数据准备好时,内核会发送一个 SIGIO
信号通知进程。进程在收到信号后,可以在信号处理函数中进行数据读取,或者在主循环中检查并读取数据。
特点:
- 非阻塞: 在等待数据准备期间,进程不阻塞,可以继续执行。
- 只通知一次: 当数据准备好时,内核只发送一次信号,避免了轮询。
- 实现复杂: 信号处理机制本身比较复杂,而且信号队列有限,在高并发场景下可能丢失信号。
流程:
- 用户进程为文件描述符设置信号处理函数,并开启
SIGIO
信号通知。 - 进程不阻塞,继续执行其他任务。
- 当数据准备好时,内核发送
SIGIO
信号给进程。 - 进程捕获
SIGIO
信号,在信号处理函数中或者主循环中调用阻塞 I/O 操作(如recvfrom
)来读取数据。
2.5 Asynchronous I/O
这是最不常见的模型,也是最高级的 I/O 模型。当用户进程发起一个异步 I/O 操作后,立刻返回,进程不阻塞,可以继续执行其他任务。当 I/O 操作完全完成(包括数据从内核缓冲区复制到用户缓冲区)后,内核会通知用户进程。
特点:
- 完全非阻塞: 用户进程在发起 I/O 操作后,无需等待任何阶段,立即返回,可以继续执行。
- 内核完成所有工作: 数据的准备、从内核到用户空间的复制都由内核完成,用户进程只负责发起和接收完成通知。
- 编程最复杂: 编程模型最复杂,且 POSIX AI/O 在实际应用中存在一些限制和平台差异。
- 适用场景: 对性能要求极高,需要充分利用 CPU 时间的场景。
流程:
- 用户进程调用
aI/O_read
(异步读操作)。 - 内核接收 I/O 请求,并立即返回给用户进程。
- 用户进程不阻塞,继续执行其他任务。
- 内核在后台完成所有数据准备和数据复制工作。
- I/O 操作完全完成后,内核通过注册的回调函数、信号或其他机制通知用户进程。
3. 阻塞I/O和非阻塞I/O的区别
我们平常说的 I/O 阻塞,指的是 I/O 在 第一阶段 是否阻塞。
进程发起 I/O 请求时不可读或者不可写,进程阻塞直到可读或者可写,就是阻塞 I/O;如果不可读或者不可写时进程返回 I/O 失败,就是非阻塞 I/O。
4. 同步和异步的区别
同步和异步的区别在于 I/O 的 第二阶段 是否阻塞:如果发起 I/O 请求成功后进程阻塞直到 I/O 完成,就是同步I/O;反之,如果进程发起 I/O 请求后可以去执行其它事,等 I/O 完了再处理,就是异步 I/O。
5. 非阻塞 I/O 的轮询问题
// 假设 sockfd 是非阻塞的
char buffer[1024];
// while(true) 就是在模拟轮询的交过
while (true) {
int bytes_read = read(sockfd, buffer, sizeof(buffer));
if (bytes_read == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据还没准备好,我可以做点别的!
// 但是,为了不错过数据,我可能马上又回到循环顶部继续read
// 这就是“忙等”
printf("No data yet, trying again...\n");
// 这里可以做一些非常短小的其他计算任务,但如果任务长,就会影响数据获取的及时性
// 真实的程序会陷入一个紧密的循环,不断执行read,消耗CPU
continue;
}
else {
// 实际错误
perror("read error");
break;
}
}
else if (bytes_read == 0) {
// 连接关闭
printf("Connection closed.\n");
break;
}
else {
// 成功读取到数据
printf("Read %d bytes: %s\n", bytes_read, buffer);
// 数据处理完毕,可以决定是继续读还是退出
break; // 例如,处理完一次就退出
}
}
6. 解决阻塞 I/O 低效率的问题
多线程+阻塞 I/O:每个客户端 socket 对应一个服务器线程,但是内存消耗比较大,服务器能管理的线程数量是有限的,本质上是以空间换时间。
7. 异步 I/O
异步 I/O 的设计理念是,当应用程序发起一个 I/O 操作时,它会向内核注册这个请求,然后立即返回,而不需要等待任何 I/O 阶段完成。内核会在后台独立地处理整个 I/O 过程,包括数据准备和数据复制。当所有工作都完成后,内核会主动通知应用程序。
因此,应用程序一般需要告知内核以下关键信息:
- 数据存放位置: 告知内核数据最终要被写入(读操作)或从哪里读取(写操作)的用户空间缓冲区地址。
- 注册回调函数或通知机制: 告知内核当 I/O 操作完全完成后,应该通过何种方式通知应用程序(例如,调用一个特定的回调函数,或者发送一个完成事件)。
8. I/O 多路复用
#include <arpa/inet.h>
#include <fcntl.h> // for fcntl
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#define MAX_EVENTS 10
#define BUFFER_SIZE 1024
#define PORT 8080
// 函数:设置文件描述符为非阻塞模式
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL");
return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL O_NONBLOCK");
return -1;
}
return 0;
}
int main() {
int listen_sock, conn_sock, epoll_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len;
struct epoll_event ev, events[MAX_EVENTS];
char buffer[BUFFER_SIZE];
int n, i;
// 1. 创建监听 socket
listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置 SO_REUSEADDR 选项,允许端口复用
int opt = 1;
if (setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) ==
-1) {
perror("setsockopt SO_REUSEADDR");
close(listen_sock);
exit(EXIT_FAILURE);
}
// 设置监听 socket 为非阻塞模式
if (set_nonblocking(listen_sock) == -1) {
close(listen_sock);
exit(EXIT_FAILURE);
}
// 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有可用 IP 地址
server_addr.sin_port = htons(PORT); // 监听 8080 端口
if (bind(listen_sock, (struct sockaddr *)&server_addr,
sizeof(server_addr)) == -1) {
perror("bind");
close(listen_sock);
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(listen_sock, SOMAXCONN) == -1) {
perror("listen");
close(listen_sock);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 2. 创建 epoll 实例
epoll_fd =
epoll_create1(0); // 参数0表示不指定大小,或者可以传入一个大于0的数
if (epoll_fd == -1) {
perror("epoll_create1");
close(listen_sock);
exit(EXIT_FAILURE);
}
// 3. 将监听 socket 添加到 epoll 实例中
ev.events = EPOLLIN | EPOLLET; // 监听读事件,并使用边缘触发模式
ev.data.fd = listen_sock;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
close(listen_sock);
close(epoll_fd);
exit(EXIT_FAILURE);
}
// 主事件循环
for (;;) {
// 4. 等待 I/O 事件的发生 (阻塞阶段)
// epoll_wait 会阻塞,直到有事件发生或超时
n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // -1 表示无限等待
if (n == -1) {
perror("epoll_wait");
close(listen_sock);
close(epoll_fd);
exit(EXIT_FAILURE);
}
// 5. 遍历所有就绪的事件
for (i = 0; i < n; i++) {
// 如果是监听 socket 上的事件,表示有新连接到来
if (events[i].data.fd == listen_sock) {
client_len = sizeof(client_addr);
// 接受新连接 (这里需要循环 accept,因为边缘触发模式下,一次
// epoll_wait 可能只通知一次,但可能有多个连接到来)
while ((conn_sock =
accept(listen_sock, (struct sockaddr *)&client_addr,
&client_len)) != -1) {
printf("Accepted connection from %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 设置新连接为非阻塞模式
if (set_nonblocking(conn_sock) == -1) {
perror("set_nonblocking conn_sock");
close(conn_sock);
continue;
}
// 将新连接添加到 epoll 实例中,监听读事件
ev.events = EPOLLIN | EPOLLET; // 同样使用边缘触发
ev.data.fd = conn_sock;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_sock, &ev) ==
-1) {
perror("epoll_ctl: conn_sock");
close(conn_sock);
}
}
if (conn_sock == -1) {
// 如果 accept 返回 -1 且 errno 是 EAGAIN 或
// EWOULDBLOCK,表示所有待处理连接都已接受
if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("accept");
}
}
} else { // 如果是客户端 socket 上的事件,表示有数据可读或连接关闭
int current_fd = events[i].data.fd;
// 检查是否是错误事件 (EPOLLERR) 或连接关闭事件 (EPOLLHUP)
if (events[i].events & (EPOLLHUP | EPOLLERR)) {
fprintf(stderr,
"Client %d disconnected or error occurred.\n",
current_fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, current_fd,
NULL); // 从 epoll 中删除
close(current_fd); // 关闭 socket
continue;
}
// 处理读事件
if (events[i].events & EPOLLIN) {
// 读取数据 (非阻塞读,这里需要循环读取,直到
// EAGAIN,因为是边缘触发)
ssize_t bytes_read;
int total_read = 0;
while ((bytes_read = read(current_fd, buffer + total_read,
BUFFER_SIZE - 1 - total_read)) >
0) {
total_read += bytes_read;
// 如果缓冲区满了,但还有数据,可能需要更大的缓冲区或进一步处理
if (total_read >= BUFFER_SIZE - 1) {
break;
}
}
buffer[total_read] = '\0'; // 确保字符串以空字符结尾
if (bytes_read == -1) {
if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("read");
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, current_fd,
NULL);
close(current_fd);
}
// 如果是
// EAGAIN/EWOULDBLOCK,表示所有已准备的数据都已读取,下次等待通知
} else if (bytes_read == 0 && total_read == 0) {
// 客户端关闭连接
printf("Client %d closed connection.\n", current_fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, current_fd, NULL);
close(current_fd);
} else {
printf("Received from client %d: %s", current_fd,
buffer);
// 回显数据给客户端
// 注意:这里也应该用非阻塞写,并处理 EAGAIN 错误,
// 但为了简化示例,这里直接使用阻塞写 (实际应该将
// EPOLLOUT 事件加入epoll,等可写时再写)
if (send(current_fd, buffer, total_read, 0) == -1) {
perror("send");
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, current_fd,
NULL);
close(current_fd);
}
}
}
}
}
}
close(listen_sock);
close(epoll_fd);
return 0;
}
9. select、poll、epoll
select
、poll
和 epoll
都是 Linux/Unix 系统中实现 I/O 多路复用 (I/O Multiplexing) 的 系统调用。它们允许单个进程(或线程)同时监控多个文件描述符(通常是网络 Socket),并在其中任何一个文件描述符就绪(可读、可写或出现异常)时通知程序,从而实现高并发的服务器。
尽管它们都属于 I/O 多路复用,但在机制、性能和使用上存在显著差异。
9.1 select
底层数据结构是 bitmap,bitmap 中的每个 bit 表示一个文件描述符是否被监听。例如,如果你想监听文件描述符 0、3、5,那么 fd_set
的第 0、3、5 位就会被设置为 1。
当我们调用 select()
时,需要提高三个 fd_set
(读、写、异常)参数,这三个 fd_set
是在应用程序(用户空间)中创建和维护的。
作为 sys_call,select()
会陷入内核态,此时我们需要将这三个 fd_set
拷贝到内核空间的内存区域,如果 bitmap 中由 1024 个比特,特就是 128 字节,那么拷贝三份就是 384 字节,每次执行 select 都需要执行 384 字节的拷贝,这个开销显然有些大了。
当内核在自己的内存中拿到 fd_set
之后,会遍历其中所有位,检查对应的文件描述符是否有相应事件发生,这个遍历是线性的,即使只有一个事件,我们也需要遍历所有文件描述符。内核会修改用户传入的 fd_set
,如果有事件发生就将该位修改为 1,反之为 0,最后再将该 fd_set
返回给用户,好吧,又是 384 比特的开销(悲。
另外,select 监听的端口还有数量限制,由 FD_SETSIZE
宏定义,并且使用 bitmap(位数组)来记录监听信息,并不支持动态扩容。
9.2 poll
poll 解决了 select 对于端口(文件描述符)监听数量的限制,它使用动态链表(struct pollfd
)作为底层数据结构。
但是,性能开销依然存在,在调用 epoll 时,我们需要将整个 pollfd
数组传入内核,在接受时,又需要将 pollfd
数组从内核返回给用户。内核依然通过线性遍历的方式查询某个文件描述符是否有相应事件发生。
9.3 epoll
如果说 select 和 poll 的区别仅仅在于,一个是固定大小(位)数组,一个可以动态拓展的数组,那么 epoll 和 select、poll 的区别就是天壤之别了,两者之间完全不是一个理念。
由于 select/poll 的区别可以简单的视为仅仅是数组是否可以动态拓展,所以下面我们以 select 为例进行说明。
select 所做的事情完全就是 “一锤子买卖”,每次调用 select,将监听信息 copy 到内核,内核处理完之后再 copy 到用户,之后内核就再也感知不到监听信息了,除非等到下一次 select 调用。如果我们调用 n 次 select,就需要 copy 2*n 次监听信息。
这就是所谓 "提交-检查-返回" 的监听模式。
- 提交信息: 每次你调用
select()
或poll()
时,你需要将所有你当前想要监听的文件描述符及其感兴趣的事件(fd_set
或pollfd
数组)完整地从用户空间传递(拷贝)到内核空间。 - 内核检查: 内核接收到这些信息后,会根据这些临时的“监听列表”去检查对应的文件描述符是否有事件发生。
- 返回结果: 检查完毕后,内核会将结果(修改后的
fd_set
或pollfd
数组)完整地拷贝回用户空间,然后系统调用返回。 - 生命周期: 这意味着,一旦
select()
或poll()
调用返回,内核就不再“记住”你之前提交的那些需要监听的文件描述符信息了。每次你下一次调用select()
或poll()
时,你都必须重新将完整的监听列表再次提交给内核。
而 epoll 的设计理念则完全不同,epoll 引入了 持久化事件管理机制。对于监听事件,我们并不是在需要 I/O 时才 copy 给内核,而是随时的将监听事件从内核中增删该,之后当我们需要进行 I/O 时,调用 epoll 就不需要再上传监听信息了,这也就意味着,内核持久化管理了我们的监听信息。
另外,为了加速对监听事件的管理(增删改),内核使用红黑树而不是动态数组来管理管理监听信息,因为红黑树的增删改都是 O(log n) 级别的,而动态数组虽然可以实现 O(1) 时间复杂度的删除和增加,但是查找是 O(n) 的,并且动态数组的增删也依赖于查找。
epoll 对于事件发生的通知也与 select 和 epoll 完全不同,epoll 会通过其内部的 回调机制,自动地将有事件发生的文件描述符添加到该 epoll 实例的就绪列表中(一般是一个双向链表),注意,这个过程是 epoll 自动执行的,不需要我们显式调用 epoll。这样当我们真正调用 epoll 时,直接访问这个双链表即可,而不用遍历红黑树。当就绪队列不为空时,epoll_wait()
会直接把列表中的就绪事件数据拷贝到用户空间提供的缓冲区中,然后立即返回。这个过程不需要遍历红黑树,也不需要遍历所有注册的 FD。其性能与就绪事件的数量成正比 (O(K)
)。如果就绪列表为空,epoll_wait()
就会阻塞,直到有新的事件发生并被添加到就绪列表中。
epoll 在 I/O 的第一阶段是阻塞的。
epoll 内部至少维护着一颗红黑树(监听事件)和一条双向链表(通知事件)。
10. epoll 的 LT 和 ET
水平触发(LT,Level Trigger)和 边缘触发(ET,Edge Trigger) 常用于 中断处理 和 IO 多路复用。
10.1 水平触发
读操作:
- 只要缓冲区内容不为空,LT 模模式就返回读秋绪
写操作:
- 只要缓冲区内容不满,LT 模式就返回写就绪
#include <unistd.h>
#include <stdio.h>
#include <sys/epoll.h>
int main()
{
int epfd, nfds;
char buf[256];
struct epoll_event event, events[5];
epfd = epoll_create(1);
event.data.fd = STDIN_FILENO;
event.events = EPOLLIN; // LT是默认模式
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event);
while (1) {
nfds = epoll_wait(epfd, events, 5, -1);
int i;
for (i = 0; i < nfds; ++i) {
if (events[i].data.fd == STDIN_FILENO) {
read(STDIN_FILENO, buf, sizeof(buf));
printf("hello world\n");
}
}
}
}
10.2 边缘触发
读操作:
- 缓冲区内容变多时
- 当缓冲区中有数据可读,并且应用进程相应的描述符进行
EPOLL_CTL_MOD
修改EPOLLIN
事件时
写操作:
- 缓冲区内容变少时
- 当缓冲区中有数据可写,并且应用进程相应的描述符进行
EPOLL_CTL_MOD
修改EPOLLOUT
事件时
#include <unistd.h>
#include <stdio.h>
#include <sys/epoll.h>
int main()
{
int epfd, nfds;
struct epoll_event event, events[5];
epfd = epoll_create(1);
event.data.fd = STDIN_FILENO;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event);
while (1) {
nfds = epoll_wait(epfd, events, 5, -1);
int i;
for (i = 0; i < nfds; ++i) {
if (events[i].data.fd == STDIN_FILENO) {
printf("hello world\n");
}
}
}
}
10.3 优缺点
有一种说法是,边缘触发只会触发一次,而水平触发需要一直除法,因此边缘触发效率更高,这并不完全对。
首先,边缘触发会导致饥饿现象,因为边缘触发需要一次性完成事件,如果这个事件的处理事件非常长,那么其它事件请求就会饥饿,那么对于其它请求的感知,就会认为服务器的延迟很高。其次,如果事件处理过程中出现了逻辑错误导致数据没有被完全处理,那么剩下的数据可能就永远不会被处理。
然后就是从跨平台性上考虑,LT 的跨平台性更好,一些非 Linux 平台可能不支持 ET。
10.4 参考
11. 高性能 I/O 模型
Proactor 前摄器模式和 Reactor 反应器模式是高性能 I/O 模型中两个非常重要的设计模式。这两个模式是实现高并发、高吞吐量 I/O 的基石,它们的核心区别在于处理 I/O 事件的时机和方式,以及与同步 I/O 和异步 I/O 的对应关系。
Proactor 用于异步 I/O,而 Reactor 用于同步 I/O。
在 Proactor 模式中:当检测到有事件发生时,会新起一个异步操作,然后交由内核线程去处理,当内核线程完成 I/O 操作之后,发送一个通知告知操作已完成;可以得知,异步 I/O 模型采用的就是 Proactor 模式。
在 Reactor 模式中,会先对每个 client 注册感兴趣的事件,然后有一个线程专门去轮询每个 client 是否有事件发生,当有事件发生时,便顺序处理每个事件,当所有事件处理完之后,便再转去继续轮询。多路复用 I/O 就是采用 Reactor 模式。当然为了提高事件处理速度,可以通过多线程或者线程池的方式来处理事件。
11.1 Proactor Model:为异步而生
- 核心思想: Proactor 的哲学是“我发起了,你完成”。应用程序发起一个 I/O 操作后,就不再关心其具体执行过程,立即返回去处理其他任务。所有的 I/O 处理(包括数据准备和数据从内核到用户空间的拷贝)都由操作系统或底层库在后台完成。
- 事件触发: Proactor 模式的事件触发点是 I/O 操作完全完成。当数据已经完全读入或写出用户缓冲区时,系统才发送一个“完成事件”给应用程序。
- 工作流程:
- 应用向内核(或 Proactor 框架)提交一个异步 I/O 请求,并提供一个回调函数或完成处理器。
- 请求立即返回,应用线程可以继续执行其他任务,不阻塞。
- 内核在后台执行整个 I/O 操作,包括数据的准备和数据的复制。
- 当 I/O 操作彻底完成时,内核会通知 Proactor 框架。
- Proactor 框架接收到完成通知后,会调用应用之前注册的回调函数,告知操作已完成,数据已就绪。
- 优点: 实现了真正的非阻塞,应用线程在 I/O 期间完全无需等待,CPU 利用率高。
- 缺点: 编程模型相对复杂(回调地狱),且需要操作系统底层对真正的异步 I/O 提供良好支持(例如 Windows 的 IOCP)。
11.2 Reactor:同步 I/O 的高效管理
与 Proactor 相反,Reactor 模式则与同步 I/O (Synchronous I/O) 结合使用,特别是你提到的多路复用 I/O。
- 核心思想: Reactor 的哲学是“有事件了,我告诉你,你来处理”。它负责监听 I/O 事件的就绪状态,一旦某个事件就绪,就通知应用程序。应用程序接收通知后,需要自己去执行同步的 I/O 操作。
- 事件触发: Reactor 模式的事件触发点是 I/O 事件就绪。例如,Socket 上有数据可读了,但数据还在内核缓冲区,尚未拷贝到用户空间。
- 工作流程:
- 应用向 Reactor(通常是
select
、poll
或epoll
等多路复用机制)注册它感兴趣的 I/O 事件(如监听 Socket 的连接请求,客户端 Socket 的数据可读事件)。 - 一个专门的线程(通常称为事件循环线程)调用
epoll_wait
等方法,阻塞等待事件发生。 - 当一个或多个注册的事件发生时,Reactor 被唤醒,并通知应用哪些文件描述符就绪了。
- 应用线程接收到通知后,针对就绪的文件描述符,主动执行同步的 I/O 操作(如
read
或write
)。在这些同步 I/O 操作的数据拷贝阶段,线程是阻塞的。
- 应用向 Reactor(通常是
- 优点: 能够以单线程或少量线程高效处理大量并发连接,避免了传统阻塞 I/O 的线程爆炸问题,且编程模型相对 Proactor 简单。
- 缺点: 在数据从内核复制到用户空间的阶段,仍然是同步阻塞的。