select/poll/epoll

先看第一阶段:数据到达与内核处理

该阶段是所有多路复用技术的基础,与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,检查就绪状态。将就绪状态写入每个pollfdrevents字段,将结果拷贝回用户空间,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);

问题在于:readfdswritefdsexceptfds既是输入参数也是输出参数。

例子:

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

相关推荐
微信api接口介绍6 小时前
微信个人发消息api
运维·服务器·开发语言·前端·网络·微信·ipad
网硕互联的小客服7 小时前
SSD和HDD存储应该如何选择?
linux·运维·服务器·网络·安全
Ronin3058 小时前
【Linux网络】进程间关系与守护进程
linux·网络·守护进程·进程间关系·前台进程·后台进程
せいしゅん青春之我8 小时前
【JavaEE初阶】网络经典面试题小小结
java·网络·笔记·网络协议·tcp/ip·java-ee
南♡黎(・ิϖ・ิ)っ8 小时前
JavaEE初阶,初识网络原理
网络·java-ee·智能路由器
NewCarRen9 小时前
未来智能网联汽车的网络安全档案建立方法
网络·自动驾驶·预期功能安全
Stanf up9 小时前
网络编程Socket套接字
linux·网络
小糖学代码9 小时前
网络:2.1加餐 - 网络命令
网络
北邮-吴怀玉10 小时前
1.4.5 大数据方法论与实践指南-安全&合规
大数据·运维·网络·数据治理