epoll 底层原理 —— 从 PCB 到就绪链表的完整路径前置阅读手写 epoll 版 TCP echo 服务器了解上层 API 后再读这篇体验最佳一个问题epoll_wait 为什么不用遍历全部 fdselect/poll 每次都要扫描全部 fd// poll遍历 2048 个槽位找就绪的for(inti0;iNFDS;i){if(_fds[i].fd-1)continue;if(_fds[i].reventsPOLLIN){/* 找到了 */}}epoll 直接拿到就绪列表而且上下文直接还给你intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){// 只遍历就绪的 n 个Conn*connevents[i].data.ptr;// 注册时存的指针原样返回}为什么答案不在 API 层面在内核。epoll 用了三样东西红黑树、回调函数、就绪链表。一张图贯穿内核数据链路task_struct (你的 ./server 进程) │ └─ files → files_struct │ └─ fd_array[] ──── 每个元素是 struct file* │ ┌────────┴────────┐ ▼ ▼ fd[3] (epoll) fd[4] (socket) │ │ struct file struct file ┌──────────┐ ┌──────────┐ │f_op→epoll│ │f_op→socket│ │private_data│ │private_data│ └────┬──────┘ └────┬──────┘ │ │ ▼ ▼ struct eventpoll struct socket ┌───────────────┐ │ │rbr (红黑树根) │ └─ sk → struct sock │rdllist(就绪链表)│ ┌─────────────────┐ │wq (等待队列) │ │sk_data_ready │← 回调函数指针 └───┬───────────┘ │sk_user_data │← 指向 epitem │ └────────┬─────────┘ │ 红黑树上挂 │ ▼ ▼ struct epitem 数据到了就调 ┌──────────────┐ │rbn ← 挂红黑树一直都在 │rdllink ← 挂就绪链表数据来了才挂 │ep ← 反指 eventpoll │ffd ← 红黑树搜索 key │event ← 你注册的 {EPOLLIN, data.ptrconn} └──────────────┘这张图就是全文的核心。下面分步走完整个链路。第一部分socket 是怎么创建的intlistenfdsocket(AF_INET,SOCK_STREAM,0);内核做了什么步骤操作结果① 分配 fd遍历 fd_array 找空位得到 fd4② 创建外壳alloc_file()file-f_op socket_file_ops③ 创建 socketkmalloc(sizeof(struct socket))file-private_data sock④ 创建协议层kmalloc(sizeof(struct sock))sock-sk sk⑤ 绑定fd_install(4, file)fd_array[4] file此时 sock 上挂着一个默认回调sk-sk_data_readysock_def_readable;// 默认回调等 epoll_ctl 来覆盖sk-sk_user_dataNULL;指针链fd_array[4] → file → private_data → socket → sk第二部分epoll 实例是怎么创建的intepfdepoll_create1(0);步骤操作结果① 分配 fd遍历 fd_array得到 fd3② 创建外壳alloc_file()file-f_op epoll_file_ops③ 创建大脑kmalloc(sizeof(struct eventpoll))rbr 空rdllist 空wq 空④ 绑定file-private_data epfd_install(3, file)struct eventpoll 全貌structeventpoll{structrb_rootrbr;// 红黑树根 → 所有被监控的 fdstructlist_headrdllist;// 就绪链表头 → 有数据的 fdwait_queue_head_twq;// 等待队列 → 谁在 epoll_wait 里睡觉};创建完是空的红黑树没有节点就绪链表没有元素等待队列没有线程。指针链fd_array[3] → file → private_data → eventpoll第三部分epoll_ctl 注册时发生了什么structepoll_eventev;ev.eventsEPOLLIN;ev.data.ptrconn;// conn 是你自己定义的连接对象指针epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);epoll_data是一个 uniontypedefunionepoll_data{void*ptr;// 存指针 → 最常用可以把连接对象存在内核里intfd;// 存 fd → 但 fd 你本来就知道存它意义不大uint32_tu32;uint64_tu64;}epoll_data_t;epoll 把整个ev原样拷贝进内核的 epitem 里。事件触发时再从 epitem原样拷回给用户态——中间不拆、不碰、不修改。这就是 select/poll 做不到的事。select 返回的是第 i 个 fd 就绪了你要自己再查一遍辅助数组才知道这个 fd 对应哪个连接对象。epoll 直接把你的指针还给你一次解引用到位。步骤操作① 找 eventpollepfd →fd_array[3]→ file → private_data → ep② 找 socklistenfd →fd_array[4]→ file → private_data → socket → sk③ 创建 epitem分配内存填入 fd、event、反向指针epi-ep ep④ 挂红黑树以{fd, file}为 key 插入O(log n)检查重复⑤装回调直接操作 socksk-sk_data_ready ep_poll_callback; sk-sk_user_data epi;步骤 ⑤ 是关键。epoll_ctl 不经过红黑树直接操作 socket 底层 sock 对象。它做了两件事把sk_data_ready从默认的sock_def_readable替换成ep_poll_callback同时把sk_user_data设为指向 epitem——这样回调触发时才能从 sock 反向找到 epitem。struct epitem 全貌structepitem{structrb_noderbn;// 线①挂在 eventpoll 的红黑树上一直都在structlist_headrdllink;// 线②挂在 eventpoll 的就绪链表上数据来了才挂structeventpoll*ep;// 线③反向指针——回调时靠它找到 eventpollstructepoll_filefdffd;// {fd, file} —— 红黑树搜索 keystructepoll_eventevent;// 你注册的 events data.ptr原样保存};三根线的职能线① rbn → 红黑树增删改查时用 ← 一直在树上 线② rdllink → 数据来了挂到就绪链表取走就摘 ← 临时挂载 线③ ep → 回调手里只有 epitem通过它找到 eventpoll家在哪儿注意内核的设计同一个 epitem 对象同时嵌在红黑树rbn和就绪链表rdllink两个容器里。不是复制是同一个对象的不同成员挂在不同容器上。第四部分数据到达 —— 回调是怎么发生的这是 epoll 区分于 select/poll 的核心机制。协议栈怎么找到 sockTCP 包里携带四元组{src_ip, src_port, dst_ip, dst_port}。内核有一张全局 hash 表tcp_hashinfo四元组一查就定位到对应的struct sock。找到 sock 后数据放进接收缓冲区然后——// 协议栈收到数据后的操作sk-sk_data_ready(sk);// 这个函数在 epoll_ctl 时已被替换回调函数的 4 跳voidep_poll_callback(structsock*sk){structepitem*episk-sk_user_data;// ① sock→epitemepoll_ctl 时设的list_add_tail(epi-rdllink,epi-ep-rdllist);// ②③ epitem 挂到 eventpoll 的就绪链表wake_up(epi-ep-wq);// ④ 叫醒 epoll_wait 上睡觉的线程}跳从哪到哪靠什么1协议栈sockhash 表tcp_hashinfo四元组→sock2sockepitemsk-sk_user_dataepoll_ctl 时设的3epitemeventpollepi-ep反向指针4epitem就绪链表epi-rdllink挂到eventpoll.rdllist整个通知路径不走红黑树。红黑树只在注册/删除时用数据通知只走回调 就绪链表——O(1)。第五部分epoll_wait 怎么取出就绪事件intepoll_wait(epfd,events,maxevents,timeout);检查 eventpoll.rdllist 是否为空 │ ├─ 空 timeout -1 → 当前线程挂到 wq 上睡觉 │ 等回调里的 wake_up 来叫醒 │ └─ 不空 → 遍历就绪链表只遍历就绪节点 for (每个就绪 epitem) { events[i] epi-event; // events data.ptr 原样拷给你 list_del(epi-rdllink); // 从就绪链表摘下——但 epitem 仍在红黑树上 } return i; // 返回就绪个数你的代码拿到后intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){Conn*conn(Conn*)events[i].data.ptr;// 直接拿到你的连接对象不需要查映射表intfdconn-fd;}——O(n) 的 n 是就绪个数不是监控总数。这就是 epoll 比 select/poll 快的根本原因。第六部分ET 和 LT —— 内核做了什么你代码该怎么写先说一个生活里的类比你家有两个传感器水位传感器LT — 水平触发只要水位超过警戒线警报就一直响。你过来排掉了半缸水但因为水位还在线上警报继续响——直到你排到线下为止。门铃ET — 边缘触发按一下响一声。按完就不响了。你戴着耳机没听到那是你的事门铃不会再响。epoll 的 LT 和 ET 就是这个区别LT只要 socket 接收缓冲区里还有数据每次epoll_wait都告诉你这个 fd 可读ET数据到达的瞬间通知你一次。之后如果缓冲区还有数据你没读完不会再通知。你只能靠自己的循环读到完内核到底做了什么不同回到我们前面第五部分epoll_wait的流程。在遍历就绪链表、list_del摘下 epitem 之后内核多了一个判断list_del(epi-rdllink); // 先摘下 if (epi-event.events EPOLLET) { // ET 模式摘完就完了什么都不做 } else { // LT 模式多问一句这个 fd 现在还就绪吗 epi-ffd.file-f_op-poll(epi-ffd.file, pt); // poll 返回还有数据 → 立刻挂回就绪链表 // 所以下次 epoll_wait 还能拿到同一个 fd }对 socket 来说f_op-poll指向的就是tcp_poll// net/ipv4/tcp.c简化unsignedinttcp_poll(structfile*file,structsocket*sock,poll_table*wait){structsock*sksock-sk;unsignedintmask0;if(!skb_queue_empty(sk-sk_receive_queue))// 接收队列非空mask|EPOLLIN;// → 标记可读returnmask;}内核做的事情很朴素查一下接收队列是不是空的非空就告诉你还能读。这就是 LT 的秘密——不是内核记住了你没读完而是每次 epoll_wait 结束后都重新确认一次。就像水位传感器不是记住你上次没排完而是每次都在测水位。用户态代码差在哪LT默认不用加任何 flagev.eventsEPOLLIN;// 没有 EPOLLET就是 LTev.data.ptrconn;epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);while(1){intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){Conn*conn(Conn*)events[i].data.ptr;charbuf[1024];intlenread(conn-fd,buf,sizeof(buf));// 读一次就行// 没读完没关系下次 epoll_wait 还会通知你}}ET加 EPOLLETev.eventsEPOLLIN|EPOLLET;// 加了 EPOLLETev.data.ptrconn;epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);while(1){intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){Conn*conn(Conn*)events[i].data.ptr;charbuf[1024];// 必须写内层循环否则剩在缓冲区的数据不会再触发通知while(1){intlenread(conn-fd,buf,sizeof(buf));if(len-1){if(errnoEAGAIN)break;// 读空了正常退出// 真正的错误break;}if(len0){/* 对端关闭 */break;}// 处理 buf ...}}}EAGAIN 是怎么回事你注意上面 ET 的代码里有一个errno EAGAIN。这个东西需要解释清楚。read()是系统调用。当你读一个非阻塞 socket缓冲区有数据 →read返回读到的字节数缓冲区空了 →read不会阻塞等数据而是立即返回 -1并设 errno EAGAINEAGAIN 的意思就是“现在没数据了但 fd 本身是正常的你等会儿再试试。”所以 ET 模式的铁律是不读到 EAGAIN 不要停。停了就可能丢事件。因为一旦你从内层 while 退出而没读完剩余数据不会触发新的通知——ET 只通知数据从无到有这个状态变化不通知数据还有这个状态。ET 为什么还要设计出来LT 不好吗LT 更好用但 ET 有一个 LT 做不到的事避免惊群。场景一个多线程程序多个线程都在epoll_wait同一个 epfd。一个连接来了数据——LT 模式内核通知 - 唤醒一个线程 - 这个线程读了一点数据但没读完 -list_del poll 检查 - 发现还有数据 - 挂回就绪链表 -再唤醒一个线程。数据没读完就会反复唤醒多个线程被叫起来抢同一个 fd这就是惊群。ET 模式内核通知一次 - 唤醒一个线程 - 这个线程循环读到 EAGAIN - 结束。不会再额外唤醒。一个 fd 一次只唤醒一个线程处理到底。怎么选场景推荐原因学习 / 写 Demo / 一般项目LT默认行为不容易出 bug高并发服务器ET避免反复通知配合非阻塞 IO 性能更好多线程共用一个 epfdET避免惊群还不能熟练处理 EAGAINLT先用 LT 写对再切 ET一个比较诚实的结论除非你真的在写 Nginx 级别的服务器LT 完全够用。ET 如果不配合非阻塞 IO能写出 bug 来。补充EPOLLOUT —— 什么时候能写全文都在讲读但你写服务器总要发数据。EPOLLOUT 的坑比 EPOLLIN 多。为什么不能上来就注册 EPOLLOUT新手常这么写ev.eventsEPOLLIN|EPOLLOUT;// 同时监听读写epoll_ctl(epfd,EPOLL_CTL_ADD,fd,ev);然后发现 epoll_wait疯狂返回CPU 飙到 100%。原因很简单。回到第六部分tcp_poll的逻辑——LT 模式下内核查现在可写吗// 发送缓冲区没满 → 可写if(sk_stream_memory_free(sk))// 大部分时候都是 truemask|EPOLLOUT;TCP 的发送缓冲区默认 16KB~几 MB你一次 write 才发几个字节。缓冲区几乎永远是空的所以 EPOLLOUT 几乎永远触发。每次 epoll_wait 都立刻返回可写就是死循环。正确的用法按需注册用完就摘// 1. 初始只注册 EPOLLINev.eventsEPOLLIN;ev.data.ptrconn;epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);// 2. 要发送数据时直接 writeintnwrite(conn-fd,buf,len);if(n-1errnoEAGAIN){// 发送缓冲区满了这时才注册 EPOLLOUT等内核通知可写conn-out_bufbuf;// 把没发完的数据存起来conn-out_lenlen;ev.eventsEPOLLIN|EPOLLOUT;epoll_ctl(epfd,EPOLL_CTL_MOD,conn-fd,ev);}// 3. 收到 EPOLLOUT 通知后if(events[i].eventsEPOLLOUT){intnwrite(conn-fd,conn-out_buf,conn-out_len);// ...处理...if(全部发完){// 摘掉 EPOLLOUT不然下次又疯狂触发ev.eventsEPOLLIN;// 只保留读epoll_ctl(epfd,EPOLL_CTL_MOD,conn-fd,ev);}}核心原则EPOLLOUT 是给发送缓冲区从满变不满这个事件用的不是给缓冲区一直有空用的。平时不要挂write 失败EAGAIN了才挂发完了立刻摘。红黑树 vs 回调两套系统各干各的epoll_ctl(ADD): ├── 系统 Aepitem 挂红黑树 → 负责增删改查管理用O(log n) └── 系统 Bsock 上装回调 → 负责数据通知通知用O(1) 数据到达只走系统 B绕过红黑树O(1) epoll_wait只碰就绪链表O(就绪个数) epoll_ctl(DEL)两套系统一起清理——红黑树摘节点 拆掉 sock 回调红黑树只在管理时用数据通知完全不经过它。如果直接close(fd)不调 DEL 呢内核在 close 时自动处理——__fput→eventpoll_release从红黑树摘除 epitem、把sk_data_ready恢复成默认回调、释放内存。不会泄露但显式 DEL 是更好的习惯。VFS万物皆文件epoll、socket、普通文件在用户态都是 int fd。内核靠struct file统一structfile{structfile_operations*f_op;// 函数指针表C 语言的多态void*private_data;// 指向真正的实体};fd 类型f_opprivate_data →epollepoll_file_opsstruct eventpollsocketsocket_file_opsstruct socket普通文件ext4_file_opsstruct inode同一个close(fd)根据f_op走不同执行路径——C 语言用函数指针表实现多态。内核角色速查结构体作用一句话task_struct进程描述符PCB进程的身份证files_structfd 表管理器进程的所有文件入口struct fileVFS 统一外壳不管底层是啥上层都是 filestruct socketBSD socket 层socket 和 sock 之间的桥梁struct sock协议层TCP/UDP 的真正实现回调装在这里struct eventpollepoll 大脑红黑树根 就绪链表头 等待队列struct epitem一个被监控的 fd两个钩子树的、链表的 反向指针 用户数据struct epoll_event用户态结构体唯一你在代码里能碰到的select 是登记簿poll 是名单卡。epoll 是装了呼叫铃的总管——内核在 socket 上装回调数据一到自动挂链、主动通知。O(1) 就绪通知O(就绪个数) 返回这就是 epoll 快的本质。上一篇手写 epoll 版 TCP echo 服务器
epoll 底层原理 —— 从 PCB 到就绪链表的完整路径
epoll 底层原理 —— 从 PCB 到就绪链表的完整路径前置阅读手写 epoll 版 TCP echo 服务器了解上层 API 后再读这篇体验最佳一个问题epoll_wait 为什么不用遍历全部 fdselect/poll 每次都要扫描全部 fd// poll遍历 2048 个槽位找就绪的for(inti0;iNFDS;i){if(_fds[i].fd-1)continue;if(_fds[i].reventsPOLLIN){/* 找到了 */}}epoll 直接拿到就绪列表而且上下文直接还给你intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){// 只遍历就绪的 n 个Conn*connevents[i].data.ptr;// 注册时存的指针原样返回}为什么答案不在 API 层面在内核。epoll 用了三样东西红黑树、回调函数、就绪链表。一张图贯穿内核数据链路task_struct (你的 ./server 进程) │ └─ files → files_struct │ └─ fd_array[] ──── 每个元素是 struct file* │ ┌────────┴────────┐ ▼ ▼ fd[3] (epoll) fd[4] (socket) │ │ struct file struct file ┌──────────┐ ┌──────────┐ │f_op→epoll│ │f_op→socket│ │private_data│ │private_data│ └────┬──────┘ └────┬──────┘ │ │ ▼ ▼ struct eventpoll struct socket ┌───────────────┐ │ │rbr (红黑树根) │ └─ sk → struct sock │rdllist(就绪链表)│ ┌─────────────────┐ │wq (等待队列) │ │sk_data_ready │← 回调函数指针 └───┬───────────┘ │sk_user_data │← 指向 epitem │ └────────┬─────────┘ │ 红黑树上挂 │ ▼ ▼ struct epitem 数据到了就调 ┌──────────────┐ │rbn ← 挂红黑树一直都在 │rdllink ← 挂就绪链表数据来了才挂 │ep ← 反指 eventpoll │ffd ← 红黑树搜索 key │event ← 你注册的 {EPOLLIN, data.ptrconn} └──────────────┘这张图就是全文的核心。下面分步走完整个链路。第一部分socket 是怎么创建的intlistenfdsocket(AF_INET,SOCK_STREAM,0);内核做了什么步骤操作结果① 分配 fd遍历 fd_array 找空位得到 fd4② 创建外壳alloc_file()file-f_op socket_file_ops③ 创建 socketkmalloc(sizeof(struct socket))file-private_data sock④ 创建协议层kmalloc(sizeof(struct sock))sock-sk sk⑤ 绑定fd_install(4, file)fd_array[4] file此时 sock 上挂着一个默认回调sk-sk_data_readysock_def_readable;// 默认回调等 epoll_ctl 来覆盖sk-sk_user_dataNULL;指针链fd_array[4] → file → private_data → socket → sk第二部分epoll 实例是怎么创建的intepfdepoll_create1(0);步骤操作结果① 分配 fd遍历 fd_array得到 fd3② 创建外壳alloc_file()file-f_op epoll_file_ops③ 创建大脑kmalloc(sizeof(struct eventpoll))rbr 空rdllist 空wq 空④ 绑定file-private_data epfd_install(3, file)struct eventpoll 全貌structeventpoll{structrb_rootrbr;// 红黑树根 → 所有被监控的 fdstructlist_headrdllist;// 就绪链表头 → 有数据的 fdwait_queue_head_twq;// 等待队列 → 谁在 epoll_wait 里睡觉};创建完是空的红黑树没有节点就绪链表没有元素等待队列没有线程。指针链fd_array[3] → file → private_data → eventpoll第三部分epoll_ctl 注册时发生了什么structepoll_eventev;ev.eventsEPOLLIN;ev.data.ptrconn;// conn 是你自己定义的连接对象指针epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);epoll_data是一个 uniontypedefunionepoll_data{void*ptr;// 存指针 → 最常用可以把连接对象存在内核里intfd;// 存 fd → 但 fd 你本来就知道存它意义不大uint32_tu32;uint64_tu64;}epoll_data_t;epoll 把整个ev原样拷贝进内核的 epitem 里。事件触发时再从 epitem原样拷回给用户态——中间不拆、不碰、不修改。这就是 select/poll 做不到的事。select 返回的是第 i 个 fd 就绪了你要自己再查一遍辅助数组才知道这个 fd 对应哪个连接对象。epoll 直接把你的指针还给你一次解引用到位。步骤操作① 找 eventpollepfd →fd_array[3]→ file → private_data → ep② 找 socklistenfd →fd_array[4]→ file → private_data → socket → sk③ 创建 epitem分配内存填入 fd、event、反向指针epi-ep ep④ 挂红黑树以{fd, file}为 key 插入O(log n)检查重复⑤装回调直接操作 socksk-sk_data_ready ep_poll_callback; sk-sk_user_data epi;步骤 ⑤ 是关键。epoll_ctl 不经过红黑树直接操作 socket 底层 sock 对象。它做了两件事把sk_data_ready从默认的sock_def_readable替换成ep_poll_callback同时把sk_user_data设为指向 epitem——这样回调触发时才能从 sock 反向找到 epitem。struct epitem 全貌structepitem{structrb_noderbn;// 线①挂在 eventpoll 的红黑树上一直都在structlist_headrdllink;// 线②挂在 eventpoll 的就绪链表上数据来了才挂structeventpoll*ep;// 线③反向指针——回调时靠它找到 eventpollstructepoll_filefdffd;// {fd, file} —— 红黑树搜索 keystructepoll_eventevent;// 你注册的 events data.ptr原样保存};三根线的职能线① rbn → 红黑树增删改查时用 ← 一直在树上 线② rdllink → 数据来了挂到就绪链表取走就摘 ← 临时挂载 线③ ep → 回调手里只有 epitem通过它找到 eventpoll家在哪儿注意内核的设计同一个 epitem 对象同时嵌在红黑树rbn和就绪链表rdllink两个容器里。不是复制是同一个对象的不同成员挂在不同容器上。第四部分数据到达 —— 回调是怎么发生的这是 epoll 区分于 select/poll 的核心机制。协议栈怎么找到 sockTCP 包里携带四元组{src_ip, src_port, dst_ip, dst_port}。内核有一张全局 hash 表tcp_hashinfo四元组一查就定位到对应的struct sock。找到 sock 后数据放进接收缓冲区然后——// 协议栈收到数据后的操作sk-sk_data_ready(sk);// 这个函数在 epoll_ctl 时已被替换回调函数的 4 跳voidep_poll_callback(structsock*sk){structepitem*episk-sk_user_data;// ① sock→epitemepoll_ctl 时设的list_add_tail(epi-rdllink,epi-ep-rdllist);// ②③ epitem 挂到 eventpoll 的就绪链表wake_up(epi-ep-wq);// ④ 叫醒 epoll_wait 上睡觉的线程}跳从哪到哪靠什么1协议栈sockhash 表tcp_hashinfo四元组→sock2sockepitemsk-sk_user_dataepoll_ctl 时设的3epitemeventpollepi-ep反向指针4epitem就绪链表epi-rdllink挂到eventpoll.rdllist整个通知路径不走红黑树。红黑树只在注册/删除时用数据通知只走回调 就绪链表——O(1)。第五部分epoll_wait 怎么取出就绪事件intepoll_wait(epfd,events,maxevents,timeout);检查 eventpoll.rdllist 是否为空 │ ├─ 空 timeout -1 → 当前线程挂到 wq 上睡觉 │ 等回调里的 wake_up 来叫醒 │ └─ 不空 → 遍历就绪链表只遍历就绪节点 for (每个就绪 epitem) { events[i] epi-event; // events data.ptr 原样拷给你 list_del(epi-rdllink); // 从就绪链表摘下——但 epitem 仍在红黑树上 } return i; // 返回就绪个数你的代码拿到后intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){Conn*conn(Conn*)events[i].data.ptr;// 直接拿到你的连接对象不需要查映射表intfdconn-fd;}——O(n) 的 n 是就绪个数不是监控总数。这就是 epoll 比 select/poll 快的根本原因。第六部分ET 和 LT —— 内核做了什么你代码该怎么写先说一个生活里的类比你家有两个传感器水位传感器LT — 水平触发只要水位超过警戒线警报就一直响。你过来排掉了半缸水但因为水位还在线上警报继续响——直到你排到线下为止。门铃ET — 边缘触发按一下响一声。按完就不响了。你戴着耳机没听到那是你的事门铃不会再响。epoll 的 LT 和 ET 就是这个区别LT只要 socket 接收缓冲区里还有数据每次epoll_wait都告诉你这个 fd 可读ET数据到达的瞬间通知你一次。之后如果缓冲区还有数据你没读完不会再通知。你只能靠自己的循环读到完内核到底做了什么不同回到我们前面第五部分epoll_wait的流程。在遍历就绪链表、list_del摘下 epitem 之后内核多了一个判断list_del(epi-rdllink); // 先摘下 if (epi-event.events EPOLLET) { // ET 模式摘完就完了什么都不做 } else { // LT 模式多问一句这个 fd 现在还就绪吗 epi-ffd.file-f_op-poll(epi-ffd.file, pt); // poll 返回还有数据 → 立刻挂回就绪链表 // 所以下次 epoll_wait 还能拿到同一个 fd }对 socket 来说f_op-poll指向的就是tcp_poll// net/ipv4/tcp.c简化unsignedinttcp_poll(structfile*file,structsocket*sock,poll_table*wait){structsock*sksock-sk;unsignedintmask0;if(!skb_queue_empty(sk-sk_receive_queue))// 接收队列非空mask|EPOLLIN;// → 标记可读returnmask;}内核做的事情很朴素查一下接收队列是不是空的非空就告诉你还能读。这就是 LT 的秘密——不是内核记住了你没读完而是每次 epoll_wait 结束后都重新确认一次。就像水位传感器不是记住你上次没排完而是每次都在测水位。用户态代码差在哪LT默认不用加任何 flagev.eventsEPOLLIN;// 没有 EPOLLET就是 LTev.data.ptrconn;epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);while(1){intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){Conn*conn(Conn*)events[i].data.ptr;charbuf[1024];intlenread(conn-fd,buf,sizeof(buf));// 读一次就行// 没读完没关系下次 epoll_wait 还会通知你}}ET加 EPOLLETev.eventsEPOLLIN|EPOLLET;// 加了 EPOLLETev.data.ptrconn;epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);while(1){intnepoll_wait(epfd,events,64,-1);for(inti0;in;i){Conn*conn(Conn*)events[i].data.ptr;charbuf[1024];// 必须写内层循环否则剩在缓冲区的数据不会再触发通知while(1){intlenread(conn-fd,buf,sizeof(buf));if(len-1){if(errnoEAGAIN)break;// 读空了正常退出// 真正的错误break;}if(len0){/* 对端关闭 */break;}// 处理 buf ...}}}EAGAIN 是怎么回事你注意上面 ET 的代码里有一个errno EAGAIN。这个东西需要解释清楚。read()是系统调用。当你读一个非阻塞 socket缓冲区有数据 →read返回读到的字节数缓冲区空了 →read不会阻塞等数据而是立即返回 -1并设 errno EAGAINEAGAIN 的意思就是“现在没数据了但 fd 本身是正常的你等会儿再试试。”所以 ET 模式的铁律是不读到 EAGAIN 不要停。停了就可能丢事件。因为一旦你从内层 while 退出而没读完剩余数据不会触发新的通知——ET 只通知数据从无到有这个状态变化不通知数据还有这个状态。ET 为什么还要设计出来LT 不好吗LT 更好用但 ET 有一个 LT 做不到的事避免惊群。场景一个多线程程序多个线程都在epoll_wait同一个 epfd。一个连接来了数据——LT 模式内核通知 - 唤醒一个线程 - 这个线程读了一点数据但没读完 -list_del poll 检查 - 发现还有数据 - 挂回就绪链表 -再唤醒一个线程。数据没读完就会反复唤醒多个线程被叫起来抢同一个 fd这就是惊群。ET 模式内核通知一次 - 唤醒一个线程 - 这个线程循环读到 EAGAIN - 结束。不会再额外唤醒。一个 fd 一次只唤醒一个线程处理到底。怎么选场景推荐原因学习 / 写 Demo / 一般项目LT默认行为不容易出 bug高并发服务器ET避免反复通知配合非阻塞 IO 性能更好多线程共用一个 epfdET避免惊群还不能熟练处理 EAGAINLT先用 LT 写对再切 ET一个比较诚实的结论除非你真的在写 Nginx 级别的服务器LT 完全够用。ET 如果不配合非阻塞 IO能写出 bug 来。补充EPOLLOUT —— 什么时候能写全文都在讲读但你写服务器总要发数据。EPOLLOUT 的坑比 EPOLLIN 多。为什么不能上来就注册 EPOLLOUT新手常这么写ev.eventsEPOLLIN|EPOLLOUT;// 同时监听读写epoll_ctl(epfd,EPOLL_CTL_ADD,fd,ev);然后发现 epoll_wait疯狂返回CPU 飙到 100%。原因很简单。回到第六部分tcp_poll的逻辑——LT 模式下内核查现在可写吗// 发送缓冲区没满 → 可写if(sk_stream_memory_free(sk))// 大部分时候都是 truemask|EPOLLOUT;TCP 的发送缓冲区默认 16KB~几 MB你一次 write 才发几个字节。缓冲区几乎永远是空的所以 EPOLLOUT 几乎永远触发。每次 epoll_wait 都立刻返回可写就是死循环。正确的用法按需注册用完就摘// 1. 初始只注册 EPOLLINev.eventsEPOLLIN;ev.data.ptrconn;epoll_ctl(epfd,EPOLL_CTL_ADD,conn-fd,ev);// 2. 要发送数据时直接 writeintnwrite(conn-fd,buf,len);if(n-1errnoEAGAIN){// 发送缓冲区满了这时才注册 EPOLLOUT等内核通知可写conn-out_bufbuf;// 把没发完的数据存起来conn-out_lenlen;ev.eventsEPOLLIN|EPOLLOUT;epoll_ctl(epfd,EPOLL_CTL_MOD,conn-fd,ev);}// 3. 收到 EPOLLOUT 通知后if(events[i].eventsEPOLLOUT){intnwrite(conn-fd,conn-out_buf,conn-out_len);// ...处理...if(全部发完){// 摘掉 EPOLLOUT不然下次又疯狂触发ev.eventsEPOLLIN;// 只保留读epoll_ctl(epfd,EPOLL_CTL_MOD,conn-fd,ev);}}核心原则EPOLLOUT 是给发送缓冲区从满变不满这个事件用的不是给缓冲区一直有空用的。平时不要挂write 失败EAGAIN了才挂发完了立刻摘。红黑树 vs 回调两套系统各干各的epoll_ctl(ADD): ├── 系统 Aepitem 挂红黑树 → 负责增删改查管理用O(log n) └── 系统 Bsock 上装回调 → 负责数据通知通知用O(1) 数据到达只走系统 B绕过红黑树O(1) epoll_wait只碰就绪链表O(就绪个数) epoll_ctl(DEL)两套系统一起清理——红黑树摘节点 拆掉 sock 回调红黑树只在管理时用数据通知完全不经过它。如果直接close(fd)不调 DEL 呢内核在 close 时自动处理——__fput→eventpoll_release从红黑树摘除 epitem、把sk_data_ready恢复成默认回调、释放内存。不会泄露但显式 DEL 是更好的习惯。VFS万物皆文件epoll、socket、普通文件在用户态都是 int fd。内核靠struct file统一structfile{structfile_operations*f_op;// 函数指针表C 语言的多态void*private_data;// 指向真正的实体};fd 类型f_opprivate_data →epollepoll_file_opsstruct eventpollsocketsocket_file_opsstruct socket普通文件ext4_file_opsstruct inode同一个close(fd)根据f_op走不同执行路径——C 语言用函数指针表实现多态。内核角色速查结构体作用一句话task_struct进程描述符PCB进程的身份证files_structfd 表管理器进程的所有文件入口struct fileVFS 统一外壳不管底层是啥上层都是 filestruct socketBSD socket 层socket 和 sock 之间的桥梁struct sock协议层TCP/UDP 的真正实现回调装在这里struct eventpollepoll 大脑红黑树根 就绪链表头 等待队列struct epitem一个被监控的 fd两个钩子树的、链表的 反向指针 用户数据struct epoll_event用户态结构体唯一你在代码里能碰到的select 是登记簿poll 是名单卡。epoll 是装了呼叫铃的总管——内核在 socket 上装回调数据一到自动挂链、主动通知。O(1) 就绪通知O(就绪个数) 返回这就是 epoll 快的本质。上一篇手写 epoll 版 TCP echo 服务器