先看第一阶段:数据到达与内核处理
该阶段是所有多路复用技术的基础,与select/poll/epoll无关。
1.DMA与硬中断:网卡收到数据包后,通过DMA的方式直接写入内存的内核缓冲区。
完成后,网卡向CPU发送一个硬中断信号。
2.硬中断处理程序:CPU中止当前工作,执行硬中断处理程序。该程序只是简单地触发一个软中断,然后尽快结束,恢复之前的工作。
3.软中断:内核的软中断守护进程会处理这个软中断。它从内核缓冲区中取出数据包,经过网络协议栈的多层解析。
4.就位:数据被放入对应Socket的接收缓冲区。
❓Q1:怎么找到对应的Socket?
A1:内核中维护了一个哈希表,key为TCP四元组{源IP,源端口,目标IP,目标端口},value为对应Socket. 在WebServer02中我们知道一个四元组可以唯一确定一个TCP连接,因此每个TCP连接对应一个独立的Socket.
协议未必是TCP,但每个协议族都有自己的匹配逻辑,思想类似。
5.唤醒等待进程:数据到达Socket缓冲区后,内核通过每个Socket维护的等待队列,检查是否有进程正阻塞在这个Socket上等待数据(例如调用了read或select)。如果有,内核将这些进程标记为可运行状态,并唤醒它们。
需要注意的是,进程与Socket并不一定是一对一或者一对多的关系,可能是多对多的关系。
select
第二阶段:select的工作流程
假设我们的用户进程已经通过select监视了多个Socket,包括刚才收到数据的那个。
6.用户态调用Select :用户进程调用select(fd_set),进入内核态。
7.内核遍历与拷贝 :内核将用户空间传入的fd_set集合(你想要监视的所有文件描述符)拷贝到内核空间。然后,遍历该集合中的每一个文件描述符。
❓Q2:为什么要通过遍历检测每个被监视的fd当前的就绪状态?
A2:检测了才知道有没有新数据到来。
❓Q3:内核怎么知道哪些文件描述符是我想要监视的?
A3:fd_set集合明确告诉内核这些就是需要特定监视的fd
❓Q4:多路复用技术与操作系统是否有关系?
A4:多路复用技术与操作系统有关系。Linux一切皆文件的哲学使得select可以监视各种fd(socket、文件、管道等)。Windows上select的实现和Linux上的不同。
8.检查就绪状态:对于每个被监视的Socket,内核会检查它的接收缓冲区是否有数据可读。如果有,说明这个fd是就绪的。如果全部都没有,进程阻塞。
勿忘:一个socket对应一个fd,因为Linux中一切皆文件。
9.将进程加入等待队列 :遍历后如果发现没有任何一个fd就绪,进程阻塞。此时select会把当前进程(也就是调用select的这个进程)加入到每一个被监视的Socket的等待队列中。
❓Q5:为什么要把加到每一个被监视的Socket的等待队列中?
A5:因为进程不知道哪个Socket会先来数据。一个进程监视了ABC三个socket,任何一个Socket有数据都要唤醒这个线程。
每个Socket在内核中都有自己的等待队列。任何阻塞在这个Socket上的进程都会把自己挂到这个队列里。这样,当数据到达时,内核才知道该唤醒哪些进程。
10.进程睡眠:将进程挂到所有被监视Socket的等待队列后,select主动调度,让出CPU,调用select的进程进入睡眠状态。
第三阶段:被唤醒与返回
11.被唤醒:当第五步发生,即任何一个被监视的Socket收到了数据,内核就会唤醒正阻塞在它等待队列上的进程(如果有多个进程阻塞在等待队列上,唤醒哪个进程?随机吗?)。
❓Q6:如何唤醒?随机还是全部唤醒?
A6:全部唤醒。因为可能有多个进程监听同一个Socket.
这是select的问题之一,英文为"thundering herd problem",中文为"惊群效应"。即多个进程被唤醒,但只有一个能真正处理数据,造成CPU浪费。
这让我想起线程池中的条件变量。它实现了阻塞在该条件变量的线程才被唤醒,避免了"没事瞎忙"的情况。
12.再次遍历:进程被唤醒后,它不知道是哪个Socket有数据了,它只知道可能有一个或者多个fd就绪了,反正就是有fd就绪了,于是select再次遍历所有被监视的fd,检查它们的就绪状态。
需要注意的是,数据是交给Socket,不是直接交给进程。数据根据四元组找到对应的Socket,放入Socket的接收缓冲区,唤醒所有监视这个Socket的进程,进程被唤醒后,通过recv(socket_fd)从Socket读取数据。
13.组装结果并返回 :找出真正就绪的fd后,将就绪的fd集合写回用户空间的fd_set,将进程从所有被监视的Socket的等待队列中移除,select系统调用返回,告诉用户进程有多少个fd就绪了。
❓Q7:内核与用户维护的是两个不同的fd_set吗?
A7:内核与用户空间维护的是两个fd_set,用户传入一个fd_set,内核修改它。实际上是在内核空间有一个副本,然后修改后再拷贝回用户空间。
14.用户态处理 :用户从select返回后,它只知道有多少个fd就绪了,因此它还要再遍历一次自己传入的fd_set,通过FD_ISSET来找出哪些fd是就绪的,再对这些fd进行读写操作。
❓Q8:为什么不直接返回一个数组,其元素是就绪的文件描述符,这样不就知道有哪些fd就绪了吗?虽然说也要遍历,但是速度肯定更快啊,因为不需要检查了,每个元素都是有效的。而且既然最终用户还要遍历一次自己传入的fd_set,那统计就绪fd的数量就几乎没有什么意义了,你充其量也只是规避了当就绪fd数量为0时还遍历数组、以及就绪fd数量极少而fd_set中元素极多的条件下仍然完整遍历数组这两种情况。
A8:这就是select的缺陷所在。这也为优化提供了空间:直接返回就绪fd列表。
❓Q9:select的大致步骤:收到消息->找对应Socket->唤醒监听该Socket的所有进程->再遍历fd_set找出就绪fd->找出这些就绪fd对应的Socket->这些对应Socket调用recv获取信息。既然在第二步中我们已经找到了对应的Socket,为什么还要遍历fd_set?
A9:事实上,数据到达时内核的确知道哪个Socket就绪了,但是select机制的设计使得进程被唤醒时,并不知道哪个Socket就绪了。因为一个进程可能监视多个Socket,进程的确被唤醒了,但是它不知道究竟是哪个fd变为就绪状态导致它被唤醒。因此它需要再遍历fd_set.抽象地说,其原因是:select的设计是"状态查询"模式,不是"事件通知"模式。可能那个年代并发连接较少,这种简单设计足够用。
这为优化提供了空间:当某个fd的状态变为就绪,唤醒进程时携带该fd的信息进行唤醒操作。
poll
poll的系统调用接口:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 请求的事件(监视什么)
short revents; // 返回的事件(实际发生了什么)
};
poll的工作流程:
1.用户态准备
struct pollfd fds[3];
fds[0].fd = socket1; fds[0].events = POLLIN;
fds[1].fd = socket2; fds[1].events = POLLIN;
fds[2].fd = socket3; fds[2].events = POLLIN;
int ret = poll(fds, 3, -1); // 无限等待
2.内核处理
将pollfd数组从用户空间拷贝到内核空间。遍历所有pollfd,检查每个fd的就绪状态(怎么检查?是看events字段的值吗?)。如果没有就绪的fd,将当前进程加入到每个被监视fd的等待队列。进程进入睡眠。
❓Q1:怎么检查每个fd的就绪状态?是查看字段的值吗?
A1:并不是查看字段的值。内核检查的是fd的实际状态:
cpp
struct pollfd fds[2];
fds[0].fd = socket1; fds[0].events = POLLIN; //用户想知道socket1是否可读
fds[1].fd = socket2; fds[1].events = POLLOUT; //用户想知道socket2是否可写
//内核检查
for (每个pollfd) {
//检查这个fd的缓冲区状态
if (fds[i].fd 的接收缓冲区有数据) {
fds[i].revents |= POLLIN; //标记为可读
}
if (fds[i].fd 的发送缓冲区有空间) {
fds[i].revents |= POLLOUT; //标记为可写
}
//内核可能发现更多事件
if (fds[i].fd 发生错误) {
fds[i].revents |= POLLERR; //即使用户没要求检查错误
}
}
events告诉内核"我对什么事件感兴趣",revents是内核告诉我们"实际发生了什么事件"。
不同文件类型有不同的poll方法。对于Socket,检查接收/发送缓冲区的状态。对于普通文件,返回默认状态。对于管道,检查是否有数据可读。
❓Q2:将pollfd数组从用户空间拷贝到内核空间的操作,是每一次循环都要做的吗?
A2:是的。每一次循环都会经历:拷贝 -> 遍历 -> 可能睡眠 -> 返回 -> 处理 这一过程。这就是poll的性能瓶颈,即使只有一个fd有数据,也要拷贝整个pollfd数组到内核,遍历所有fd,如果未就绪,将进程挂到所有fd的等待队列,阻塞。循环遍历数组,一旦发现有fd就绪,唤醒在该fd等待队列中的所有进程,这些进程再遍历数组,找到变为就绪状态的fd,拷贝整个pollfd数组回用户空间。
❓Q3:为什么要拷贝整个pollfd数组呢?既然找到了变为就绪状态的fd,直接把变为就绪状态的fd打包成数组给回用户空间不就可以了吗?
A3:这确实是poll的缺陷。它延续了select的"状态查询"哲学:
用户:告诉我所有这些fd的当前状态。
内核:好的,这是所有fd的完整状态报告。
而不是"事件通知"模式:这些fd刚刚发生了事件。
如果只返回就绪fd,用户会丢失重要信息:哪个fd原本关注POLLOUT?哪个fd原本关注POLLPRI?
这就造成了内存带宽浪费、CPU缓存污染以及用户态不必要的遍历。
3.数据到达
数据到达Socket -> 内核协议栈处理 -> 放入Socket接收缓冲区 -> 内核唤醒该Socket等待队列中的所有进程(这就是前面的第一阶段)
被唤醒的进程重新遍历所有pollfd,检查就绪状态。将就绪状态写入每个pollfd的revents字段,将结果拷贝回用户空间,poll返回。
4.用户态处理结果
cpp
for (int i = 0; i < 3; i++) {
if (fds[i].revents & POLLIN) {
// 这个fd有数据可读
recv(fds[i].fd, buffer, size, 0);
}
}
总结一下poll的整个流程:
将pollfd数组从用户空间拷贝到内核空间 -> 遍历数组以检查就绪状态
路线一:全部都未就绪 -> 进程阻塞在poll系统调用中 -> 等待被唤醒
路线二:有fd转为就绪状态 -> 唤醒处于该fd等待队列上的所有进程 -> 被唤醒的进程再遍历pollfd数组 -> 找出变为就绪状态的fd -> 调用recv函数获取消息
再来回顾一下select的整个流程:
将用户空间传入的fd_set集合(表示所有用户想要监听的fd的集合)拷贝至内核空间 -> 遍历集合、检查Socket的缓冲区是否为空以确认其就绪状态
路线一:全部都未就绪 -> 进程阻塞在select系统调用中 -> 等待被唤醒
路线二:有fd转为就绪状态 -> 唤醒处于该fd等待队列上的所有进程 -> 被唤醒的进程再遍历fd_set -> 找出变为就绪状态的fd -> 调用recv函数获取消息
只有当有fd就绪或超时时,进程才会被唤醒并返回。
这么看来poll的流程和select的流程并没有太大差别,只不过它的改进在于:
1.突破文件描述符数量限制
| 机制 | 限制 | 原因 |
|---|---|---|
| select | FD_SETSIZE(通常1024) | 使用固定大小的fd_set位图 |
| poll | 理论上无限制 | 使用动态数组,只受系统资源限制 |
select使用位图,虽然大小固定,但是内存紧凑(缓存友好,但受1024限制)
poll使用结构数组,虽然内存分散,但是没有限制(内存不连续,缓存不友好)
2.更清晰的事件分离
cpp
//fd_set在select返回后会被内核修改
fd_set read_fds, write_fds, error_fds;
FD_SET(socket1, &read_fds);
FD_SET(socket2, &write_fds);
//返回后原始值被破坏,需要重新设置
//poll输入输出分离
fds[0].fd = socket1;
fds[0].events = POLLIN | POLLOUT; //输入:想监视什么
//返回后:
//fds[0].revents 包含实际发生的事件 //输出:实际发生了什么
//events字段保持不变,无需重置
理解被重置:你带着你的购物清单=[牛奶,鸡蛋,面包]去购物。超市发现面包没货,只给你返回了[牛奶,鸡蛋],那你下次就必须要更新这个清单,因为你本来还想要面包的。
cpp
//完整的购物清单
fd_set shopping_list;
FD_ZERO(&shopping_list); //清空清单
FD_SET(牛奶, &shopping_list); //需要牛奶
FD_SET(面包, &shopping_list); //需要面包
FD_SET(鸡蛋, &shopping_list); //需要鸡蛋
//询问超市这些商品是否有货
select(最大商品号+1, &shopping_list, NULL, NULL, NULL);
//超市发现面包没货,返回时清单被修改
//shopping_list = [牛奶, 鸡蛋]
这是因为select的接口设计:
cpp
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
问题在于:readfds、writefds、exceptfds既是输入参数也是输出参数。
例子:
cpp
#include <sys/select.h>
int main() {
fd_set readfds;
int max_fd = 0;
//监视3个socket
int sock1, sock2, sock3;
while (1) {
//重置
FD_ZERO(&readfds); //清空集合
FD_SET(sock1, &readfds); //重新添加sock1
FD_SET(sock2, &readfds); //重新添加sock2
FD_SET(sock3, &readfds); //重新添加sock3
if (sock1 > max_fd) max_fd = sock1;
if (sock2 > max_fd) max_fd = sock2;
if (sock3 > max_fd) max_fd = sock3;
int ready = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (ready > 0) {
//select返回后,readfds中只有就绪的socket
//其他位都被清零了
if (FD_ISSET(sock1, &readfds)) { /*处理sock1*/ }
if (FD_ISSET(sock2, &readfds)) { /*处理sock2*/ }
if (FD_ISSET(sock3, &readfds)) { /*处理sock3*/ }
}
}
}
如果忘记重置fd_set
cpp
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);
while (1) {
//忘记重置fd_set
int ready = select(max_fd+1, &readfds, NULL, NULL, NULL);
//第一次循环:readfds包含[sock1, sock2]
//假设只有sock1就绪,select返回后readfds变为[sock1]
//第二次循环:readfds仍然是[sock1]!
//丢失了对sock2的监视
}
循环着循环着,要监视的Socket不知不觉溜完了。虽然说最终我们返回的是就绪的fd,这似乎对select做无用功(遍历整个数组)的情况做出了优化,但是Socket是持续使用的,调用recv后其缓冲区变为空,又要阻塞在上面等待新的消息,而不是用完一次就关闭。这就好比你不可能每发一条信息都换一部新手机。
poll的改进:
cpp
struct pollfd fds[3];
fds[0].fd = sock1; fds[0].events = POLLIN; // 输入始终不变
fds[1].fd = sock2; fds[1].events = POLLIN;
fds[2].fd = sock3; fds[2].events = POLLIN;
while (1) {
int ready = poll(fds, 3, -1);
if (ready > 0) {
//fds[0].events 保持不变,仍然表示我们关心POLLIN
//fds[0].revents 包含实际发生的事件
if (fds[0].revents & POLLIN) { /*处理sock1*/ }
if (fds[1].revents & POLLIN) { /*处理sock2*/ }
if (fds[2].revents & POLLIN) { /*处理sock3*/ }
}
//不需要重置,events字段始终保持我们想要监视的事件
}
3.更丰富的事件类型
cpp
//poll支持更多事件类型
POLLIN //数据可读
POLLPRI //紧急数据可读
POLLOUT //数据可写
POLLRDHUP //对端关闭连接(Linux特有)
POLLERR //错误发生
POLLHUP //挂起
POLLNVAL //文件描述符未打开
epoll
它是Linux下性能最高的I/O多路复用机制,解决了select和poll的所有核心缺陷。
epoll的三个核心系统调用接口
cpp
#include <sys/epoll.h>
// 1. 创建 epoll 实例
int epoll_create(int size);
// 2. 管理 epoll 监视列表
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 等待事件
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
阶段一:epoll初始化
用户进程要使用epoll时,首先会调用epoll_create(size)(size参数已废弃,仅为兼容),内核会做两件事情:
1.创建一个epoll实例(内核中的struct eventpoll结构体),作为后续所有操作的"总管"
2.初始化两个核心数据结构:
红黑树:用于存储用户进程需要监控的所有fd及其事件(如EPOLLIN可读、EPOLLOUT可写)。红黑树插入、删除、查找的时间复杂度均为O(logN),同时支持大量fd
就绪链表(双向):用于临时存放"已就绪"的fd. 当某个fd的事件触发,内核会快速将其加入链表,避免后续轮询。
阶段二:注册监控事件
用户进程通过epoll_ctl(epfd, op, fd, event)向epoll实例注册、修改、删除要监控的fd及事件,内核会执行:
1.检查并添加到红黑树:
若fd未在红黑树中(首次注册),则创建一个struct epitem结构体(记录fd、监控的事件、对应的socket等),插入红黑树。
若已存在,则更新其监控的事件(如从EPOLLIN改为EPOLLOUT)
2.设置回调函数:
内核会为该fd对应的socket设置一个回调函数ep_poll_callback,当socket的事件就绪时(如接收缓冲区有数据),自动将对应epitem加入epoll实例的就绪链表。
阶段三:等待事件就绪
用户进程通过epoll_wait(epfd, events, maxevents, timeout)阻塞等待就绪事件,流程如下:
1.阻塞阶段:进程挂起等待
当调用epoll_wait时,内核首先检查该epoll实例的就绪链表是否为空
若为空,则将当前进程加入epoll实例的等待队列,并将进程状态改为"可中断睡眠",让出CPU
若不为空,则直接跳至"唤醒处理"阶段。
2.事件触发:就绪链表被填充
当socket因数据到达被标记为"可读就绪"时,内核会触发该socket对应的回调函数。
回调函数将该socket对应的epitem(即fd)加入epoll实例的就绪链表。若epoll实例的等待队列中有睡眠的进程(即有进程阻塞在epoll_wait),则内核会将这些进程从等待队列中移除,状态改为"就绪",加入CPU就绪队列等待调度。
❓Q:是唤醒所有在等待队列中的进程吗?
A:Linux 2.6之后,内核针对epoll做了惊群抑制优化。当就绪链表非空时,内核会优先唤醒一个线程,只有在第一个进程无法处理事件(如被信号中断)时,才会唤醒后续进程。被唤醒的进程处理完事件后,会清空就绪链表(取决于触发模式)。后续即使有其它进程被误唤醒,也会因为就绪链表为空而立即阻塞。
我们可以让一个epoll实例只被一个进程(或线程)监控(用单线程处理epoll事件,再分发任务给其它线程),这样等待队列中只有一个进程,也就可以实现精准唤醒了。
3.唤醒处理:返回就绪事件
被唤醒的进程重新获得CPU后,继续执行epoll_wait的剩余逻辑
内核遍历就绪链表,将其中所有就绪fd的事件(如EPOLLIN)拷贝到用户态传入的events数组中(只拷贝就绪的)
清空就绪链表(或保留未处理的,取决于触发模式),epoll_wait返回就绪fd的数量
❓Q:为什么要返回就绪fd的数量?
A:1.明确有效数据范围,避免无效遍历
events数组是用户态预先分配的,大小由maxevents指定,但内核只会将实际就绪的fd事件拷贝到数组中,且拷贝的数量不会超过maxevents,以防止数组越界。
例如,用户分配了maxevents=1024的数组,但实际只有3个fd就绪,内核会将这3个事件写入数组的前三个位置,而数组剩余的1021个位置是无效的(未初始化或保留就职)。此时epoll_wait返回3,用户态可以直接循环0~2索引处理,无需遍历整个数组。
2.告知用户是否有事件被截断
若就绪fd的数量超过maxevents,内核只会拷贝maxevents个事件到数组中,剩余的仍留在就绪链表中。用户态通过返回值等于maxevents,可以判断可能有未处理的就绪事件,从而立即再次调用epoll_wait处理剩余事件,避免遗漏。
❓Q:为什么红黑树似乎没有发挥什么作用?
A:红黑树的作用主要有三个:
1.快速判断fd是否已被监控
当用户通过epoll_ctl添加一个fd,内核要先判断这个fd是否已经在监控列表中。如果用链表或者数组存储监控的fd,那就是O(N),红黑树则是O(logN),哪怕是监控百万级fd,也能快速判断是否已存在
2.高效维护"fd与监控事件的关联"
每个被监控的fd都需要关联其"感兴趣的事件"(如EPOLLIN可读、EPOLLOUT可写),以及对应的socket指针、回调函数等元数据。红黑树的节点epitem正是存储这些元数据的载体。当用户修改事件时,内核通过红黑树快速找到该fd对应的epitem直接更新事件字段。删除fd时也能快速从树中移除节点。
反推:如果没有红黑树会怎样?
epoll_ctl的添加/修改/删除操作会变成O(N)
内核需要遍历整个链表/数组才能找到socket对应的epitem
总结一下epoll的流程:创建epoll实例 -> 内核创建一个eventpoll结构体 -> 为其创建该epoll专属的红黑树根节点、就绪链表头以及等待队列 -> 用户通过epoll_ctl注册事件 -> 内核将存储元数据的载体epitem加入红黑树 -> 内核为该socket设置一个回调函数并将epitem指针直接关联到对应的socket结构体 -> 用户通过epoll_wait阻塞等待就绪事件 -> 检查该epoll的就绪链表是否为空
路线一:如果链表为空 -> 将当前进程加入该epoll的等待队列 -> 将当前进程的状态调整为可中断睡眠 -> 让出CPU
路线二:socket因有数据到来被标记为"可读就绪"(假定用户关心的事件为是否可读) -> 内核执行该socket的回调函数 -> 内核通过socket的关联字段拿到epitem并通过epitem的ep指针找到所属的epoll实例 -> 将该epitem加入该epoll的就绪链表(有活干了) -> 发现该epoll的等待队列为不为空(有人手能处理新来的活)-> 优先唤醒一个进程 -> 内核遍历就绪链表(优化点:直接遍历已就绪的fd) -> 将已就绪的fd拷贝到用户传入的events数组中 -> 返回已就绪fd的数量 -> 用户遍历处理 ( -> 发现已就绪fd数量大于maxevents -> 用户再次调用epoll_wait时内核直接处理就绪链表中剩余的事件 )
再来回顾一下select的整个流程:
将用户空间传入的fd_set集合(表示所有用户想要监听的fd的集合)从用户态内存拷贝至内核态内存(低效点:每次调用都要拷贝) -> 遍历集合、检查Socket的缓冲区是否为空以确认其就绪状态
路线一:全部都未就绪 -> 进程阻塞在select系统调用中 -> 将阻塞进程加入所有它关心的fd的等待队列 -> 等待被唤醒
路线二:有fd转为就绪状态 -> 唤醒处于该fd等待队列上的所有进程 -> 被唤醒的进程再遍历fd_set -> 找出变为就绪状态的fd -> 调用recv函数获取消息
也就是说,select/poll的相似点在于,它们都要循环遍历位图/数组来判断用户关心的事件是否就绪。而epoll的优势在于避免了没事瞎忙,只有用户关心的事件就绪了,socket对应的回调函数才会被调用,内核才会遍历就绪链表。
而且,select/poll唤醒了所有进程,然后被唤醒的进程都要再遍历一遍位图/集合才能找到哪个fd变为就绪状态,本质原因是进程与socket并不是一对一或者一对多的关系。而epoll给出的解决方案是:给epoll实例配备一个等待队列,这样等待队列中的进程都关心epoll实例对应的socket,当socket变为就绪状态,从队列中拿出一个进程即可。这样就省去了一次遍历。
LT 与 ET
前面说到,拷贝就绪fd后是否清空就绪链表,这取决于触发模式。
水平触发(Level Triggered, LT)
LT是epoll的默认触发模式,行为类似select/poll:只要socket的缓冲区处于"就绪状态"(如接收缓冲区有数据未读),每次调用epoll_wait都会返回该fd,直到缓冲区状态改变(如数据被读完)。具体流程如下:
1.事件就绪:socket接收缓冲区收到数据,假设为100字节,内核标记其为可读,触发回调将fd加入epoll就绪链表
2.首次通知:用户调用epoll_wait,内核返回该fd,用户读取50字节
3.再次通知:由于缓冲区仍有数据,"可读状态"依然存在,下次调用epoll_wait时,内核会再次返回该fd,内核会再次返回该fd,直到用户把剩余50字节读完
4.状态清除:当用户读完所有数据,"可读状态"消失,后续epoll_wait不再返回该fd,直到新数据到来
边缘触发(Edge Triggered, ET)
ET模式是epoll的高效模式:仅关注是否有新数据到达缓冲区。后续即使缓冲区仍有数据未读,也不会再通知,直到有新数据到来
还是按照上面的流程来说,如果用户只读取50字节,由于缓冲区状态没有发生变化,后续调用epoll_wait也不会再返回该fd,剩余的50字节被遗忘,除非有新数据到来。如果这是又有20字节被写入缓冲区,那么就会触发ET,epoll_wait返回该fd
PS:这里说epoll_wait返回该fd,指的是epoll_wait通过events数组将该fd的就绪信息返回给用户,强调的是用户能拿到具体是哪个fd就绪。epoll_wait函数的返回值是就绪的fd数量,前面说过。
| 对比维度 | 水平触发(LT,默认) | 边缘触发(ET,高效) |
|---|---|---|
| 1. 触发判断标准 | 关注缓冲区状态:只要缓冲区处于 "就绪状态"(如可读 = 非空、可写 = 未满),就认为事件就绪 | 关注状态变化动作:仅当缓冲区从 "未就绪" 变为 "就绪"(如可读 = 新数据到达、可写 = 空间从满变未满),才认为事件就绪 |
| 2. 通知频率 | 高:只要就绪状态持续(如缓冲区有残留数据),每次调用epoll_wait都会重复通知该 fd |
低:仅在状态变化瞬间通知一次,后续即使就绪状态持续(如残留数据未读),也不再通知,直到下一次状态变化 |
| 3. 数据处理要求 | 宽松:无需一次处理完缓冲区数据,可分多次读取 / 写入(后续通知会提醒处理剩余数据) | 严格:必须一次处理完所有数据(配合非阻塞 IO 循环),否则残留数据会被 "遗忘",直到新数据到来才会再次通知 |
| 4. 与 IO 模式兼容性 | 兼容阻塞 IO:即使使用阻塞 IO,未读完数据也会被后续通知,不会导致进程长期阻塞 | 仅兼容非阻塞 IO:若用阻塞 IO,一次未读完数据会导致read/write阻塞,无法处理其他 fd 事件 |
| 5. 系统调用次数 | 多:同一批数据可能触发多次epoll_wait(每次通知对应一次调用) |
少:同一批数据(含后续新增数据)仅触发一次epoll_wait,大幅减少系统调用开销 |
| 6. 典型适用场景 | 通用场景、开发效率优先(如简单服务、原型开发);对性能要求不高,且希望逻辑简单 | 高并发高性能场景(如 Nginx、Redis);对系统调用开销敏感,能接受更复杂的处理逻辑 |
阻塞I/O:拿不到资源就睡觉;非阻塞I/O:拿不到资源就走,下次再尝试。
❓Q:这和是否清空链表有什么关系?
A:就绪链表的功能是什么?它是每个epoll实例的待处理fd列表。fd就绪时,内核会把对应的epitem加入链表,epoll_wait会从链表中读取epitem再返回给用户态。
LT模式:其核心是只要该fd仍处于就绪状态(如缓冲区有数据),下次epoll_wait还要返回它。所以当epoll_wait读取到某个epitem后,不会把这个epitem从就绪链表中移除,而是继续保留,直到该fd的就绪状态消失(如用户读完所有数据,缓冲区为空),内核才会把对应的epitem从就绪链表中删除。
ET模式:当epoll_wait读取到某个epitem后,会立即把这个epitem从就绪链表中移除。只有当fd再次发生状态变化(如新数据到达),内核才会重新把epitem加入就绪链表。
例子:假设就绪链表原本为空,fd1的epitem因新数据到来被尾插入链表。epoll_wait遍历链表,将fd1返回给用户。用户未读完数据,fd1的epitem从链表中移除,后续fd1有新数据到来,再次被尾插入链表。下次epoll_wait遍历链表,依然会拿到fd1并返回给用户。
如果ET模式使用阻塞I/O会怎样?
例子:
1.初始处理阶段:新数据 100 字节到来 -> ET 通知→epoll_wait 返回 fd1 -> 进程调用阻塞 read 读 50 字节(剩余 50 字节)-> 再调用阻塞 read 读 50 字节(缓冲区空)
2.错误循环开始:进程误以为可能还有数据,第三次调用阻塞 read -> 此时缓冲区空,进程从运行态变为阻塞在 read 上(脱离 epoll_wait 的等待队列)
3.新数据到来时的唤醒:新数据 50 字节到来 -> 内核触发回调 -> epitem 重新加入就绪链表 -> 内核尝试唤醒 epoll 实例等待队列中的进程 -> 但此时进程不在 epoll 的等待队列中(它阻塞在 read 上),所以唤醒无效
4.read 的被动唤醒(看似有效,实则更糟):新数据 50 字节会让 socket 缓冲区非空,内核会自动唤醒阻塞在 read 上的进程(这是 read 阻塞的唤醒机制,与 epoll 无关) -> 进程从 read 阻塞中恢复,读取 50 字节(缓冲区空)
5.致命循环:进程读完 50 字节后,可能再次调用阻塞 read(因为 ET 不会通过 epoll_wait 通知 "新数据已处理完") -> 缓冲区空,进程再次阻塞在 read 上 -> 重复步骤 3-4,永远卡在 "read 阻塞 -> 被新数据唤醒 -> 再 read 阻塞" 的循环中,无法回到 epoll_wait 去处理其他 fd 的事件(如其他 socket 的新连接或数据)
唤醒的是 "等待队列",而非 "任意阻塞的进程"
epoll 的唤醒只能唤醒阻塞在 epoll_wait 等待队列中的进程
read 的唤醒只能唤醒阻塞在该 fd 的 read 等待队列中的进程
在 ET + 阻塞 I/O 中,进程一旦进入 "read 阻塞",就脱离了 epoll 的等待队列,后续新数据的 epoll 唤醒对它无效;虽然 read 会被新数据唤醒,但进程处理完后又会再次阻塞在 read 上,永远无法回到 epoll_wait 处理其他事件,看似能处理新数据,实则服务已卡死
而ET + 非阻塞 I/O 的流程是:
新数据到来 -> epoll_wait 返回 fd1 -> 进程用非阻塞 read 循环读取,直到返回 EAGAIN(缓冲区空) -> 进程主动回到 epoll_wait 阻塞(进入 epoll 的等待队列)
新数据再次到来 -> 内核唤醒 epoll 等待队列中的进程 -> 进程从 epoll_wait 返回,重复步骤 1
此时,进程始终在 "epoll_wait 阻塞 -> 处理数据 -> 回到 epoll_wait" 的循环中,永远不会阻塞在 read 上,因此新数据的唤醒机制能正常生效,且进程能处理其他 fd 的事件
总结就是:进程阻塞在某个fd的read/write系统调用上,脱离了epoll_wait的调度,整个事件循环机制瘫痪。
如果LT模式使用非阻塞I/O会怎样?
这是可以的,只要我们保证epoll_wait通知后再调用read,就不会出现read盲目轮询的情况。
在ET模式下,如果一次循环读/写没有彻底完成(比如read返回EAGAIN),下一次该如何知道这个fd还有残留数据需要处理?
我们无法知道,也不需要知道。 这正是ET模式的设计哲学。在ET模式下,应用逻辑不应该关心"是否还有残留数据",而只应该关心"在本次通知中,我是否已经尽力读/写 "。我们的责任是在这次被唤醒后,通过循环调用非阻塞的read/write,直到返回EAGAIN,将这一波数据变化处理干净。之后,这个fd就会被遗忘,直到下一次新的数据到达触发新的通知。
在LT模式下,如果一直不读取数据会怎样?
内核会持续通知,每次调用epoll_wait都会返回这个fd。这会造成空转,浪费CPU资源。但这只是效率问题,不会像ET+阻塞IO那样导致程序卡死。这反过来也说明,即使是LT模式,也应该及时处理就绪的fd