深入浅出理解epoll原理

📅 2026/7/22 16:26:32
深入浅出理解epoll原理
在 Linux 系统之中有一个核心武器epoll 池在高并发的高吞吐的 IO 系统中常常见到 epoll 的身影。IO 多路复用在 Go 里最核心的是 Goroutine 也就是所谓的协程协程最妙的一个实现就是异步的代码长的跟同步代码一样。比如在 Go 中网络 IO 的readwrite看似都是同步代码其实底下都是异步调用一般流程是write ( /* IO 参数 */ ) 请求入队 等待完成 后台 loop 程序 发送网络请求 唤醒业务方Go 配合协程在网络 IO 上实现了异步流程的代码同步化。核心就是用 epoll 池来管理网络 fd 。实现形式上后台的程序只需要 1 个就可以负责管理多个 fd 句柄负责应对所有的业务方的 IO 请求。这种一对多的 IO 模式我们就叫做 IO 多路复用。多路是指多个业务方句柄并发下来的 IO 。复用是指复用这一个后台处理程序。站在 IO 系统设计人员的角度业务方咱们没办法提要求因为业务是上帝只有你服从的份他们要创建多个 fd那么你就需要负责这些 fd 的处理并且最好还要并发起来。业务方没法提要求那么只能要求后台 loop 程序了要求什么呢快快快这就是最核心的要求处理一定要快要给每一个 fd 通道最快的感受要让每一个 fd 觉得你只在给他一个人跑腿。那有人又问了那我一个 IO 请求比如 write 对应一个线程来处理这样所有的 IO 不都并发了吗是可以但是有瓶颈线程数一旦多了性能是反倒会差的。这里不再对比多线程和 IO 多路复用实现高并发之间的区别详细的可以去了解下 nginx 和 redis 高并发的秘密。1最朴实的实现方式我不用任何其他系统调用能否实现 IO 多路复用可以的。那么写个for循环每次都尝试 IO 一下读/写到了就处理读/写不到就sleep下。这样我们不就实现了 1 对多的 IO 多路复用嘛。while True: for each 句柄数组 { read/write(fd, /* 参数 */) } sleep(1s)慢着有个问题上面的程序可能会被卡死在第三行使得整个系统不得运行为什么默认情况下我们create出的句柄是阻塞类型的。我们读数据的时候如果数据还没准备好是会需要等待的当我们写数据的时候如果还没准备好默认也会卡住等待。所以在上面伪代码第三行是可能被直接卡死而导致整个线程都得到不到运行。举个例子现在有 111213 这 3 个句柄现在 11 读写都没有准备好只要read/write(11, /*参数*/)就会被卡住但 1213 这两个句柄都准备好了那遍历句柄数组 111213 的时候就会卡死在前面后面 1213 则得不到运行。这不符合我们的预期因为我们 IO 多路复用的 loop 线程是公共服务不能因为一个 fd 就直接瘫痪。那这个问题怎么解决只需要把 fd 都设置成非阻塞模式。这样read/write的时候如果数据没准备好返回EAGIN的错误即可不会卡住线程从而整个系统就运转起来了。比如上面句柄 11 还未就绪那么read/write(11, /*参数*/)不会阻塞只会报个EAGIN的错误这种错误需要特殊处理然后 loop 线程可以继续执行 1213 的读写。// 假设已有三个 fd11, 12, 13均已设置为非阻塞模式 fds [11, 12, 13] while true: for each fd in fds: buf allocate_buffer() result read(fd, buf, size) // 非阻塞读 if result -1 and errno EAGAIN: // 数据未就绪跳过不阻塞 continue if result -1: // 其他错误如连接关闭处理错误 continue if result 0: // 对端关闭连接 close(fd) remove fd from fds continue // 成功读取到数据 handle_data(fd, buf, result) // 所有 fd 轮询一遍后休眠 1 秒 sleep(1 second)以上就是最朴实的 IO 多路复用的实现了。但好像在生产环境没见过这种 IO 多路复用的实现为什么因为还不够高级。for循环每次要定期sleep 1s这个会导致吞吐能力极差因为很可能在刚好要sleep的时候所有的 fd 都准备好 IO 数据而这个时候却要硬生生的等待 1s可想而知。。。那有同学又要质疑了那for循环里面就不sleep嘛这样不就能及时处理了吗及时是及时了但是 CPU 估计要跑飞了。不加sleep那在没有 fd 需要处理的时候估计 CPU 都要跑到 100% 了。这个也是无法接受的。纠结了那sleep吞吐不行不sleep浪费 cpu怎么办这种情况用户态很难有所作为只能求助内核来提供机制协助来。因为内核才能及时的管理这些事件的通知和调度。我们再梳理下 IO 多路复用的需求和原理。IO 多路复用就是 1 个线程处理 多个 fd 的模式。我们的要求是这个 “1” 就要尽可能的快避免一切无效工作要把所有的时间都用在处理句柄的 IO 上不能有任何空转sleep 的时间浪费。有没有一种工具我们把一箩筐的 fd 放到里面只要有一个 fd 能够读写数据后台 loop 线程就要立马唤醒全部马力跑起来。其他时间要把 cpu 让出去。能做到吗能但这种需求只能内核提供机制满足你。2这事 Linux 内核必须要给个说法是的想要不用 sleep 这种辣眼睛的实现Linux 内核必须出手了毕竟 IO 的处理都是内核之中数据好没好内核最清楚。内核一口气提供了 3 种工具selectpollepoll。为什么有 3 种历史不断改进矬 - 较矬 - 卧槽、高效的演变而已。Linux 还有其他方式可以实现 IO 多路复用吗好像没有了这 3 种到底是做啥的这 3 种都能够管理 fd 的可读可写事件在所有 fd 不可读不可写无所事事的时候可以阻塞线程切走 cpu 。fd 有情况的时候都要线程能够要能被唤醒。而这三种方式以 epoll 池的效率最高。为什么效率最高其实很简单这里不详说其实无非就是 epoll 做的无用功最少select 和 poll 或多或少都要多余的拷贝盲猜遍历才知道fd 所以效率自然就低了。举个例子以 select 和 epoll 来对比举例池子里管理了 1024 个句柄loop 线程被唤醒的时候select 都是蒙的都不知道这 1024 个 fd 里谁 IO 准备好了。这种情况怎么办只能遍历这 1024 个 fd 一个个测试。假如只有一个句柄准备好了那相当于做了 1 千多倍的无效功。epoll 则不同从epoll_wait醒来的时候就能精确的拿到就绪的 fd 数组不需要任何测试拿到的就是要处理的。epoll 池原理下面我们看一下 epoll 池的使用和原理。1epoll 涉及的系统调用epoll 的使用非常简单只有下面 3 个系统调用。epoll_create epollctl epollwait就这是的就这么简单。epollcreate负责创建一个池子一个监控和管理句柄 fd 的池子epollctl负责管理这个池子里的 fd 增、删、改epollwait就是负责打盹的让出 CPU 调度但是只要有“事”立马会从这里唤醒2epoll 高效的原理Linux 下epoll 一直被吹爆作为高并发 IO 实现的秘密武器。其中原理其实非常朴实epoll 的实现几乎没有做任何无效功。我们从使用的角度切入来一步步分析下。首先epoll 的第一步是创建一个池子。这个使用epoll_create来做int epoll_create(int size);示例epollfd epoll_create(1024); if (epollfd -1) { perror(epoll_create); exit(EXIT_FAILURE); }这个池子对我们来说是黑盒这个黑盒是用来装 fd 的我们暂不纠结其中细节。我们拿到了一个epollfd这个epollfd就能唯一代表这个 epoll 池。注意这里又有一个细节用户可以创建多个 epoll 池。然后我们就要往这个 epoll 池里放 fd 了这就要用到epoll_ctl了int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);示例if (epoll_ctl(epollfd, EPOLL_CTL_ADD, 11, ev) -1) { perror(epoll_ctl: listen_sock); exit(EXIT_FAILURE); }上面我们就把句柄 11 放到这个池子里了opEPOLL_CTL_ADD表明操作是增加、修改、删除event 结构体可以指定监听事件类型可读、可写。第一个跟高效相关的问题来了添加 fd 进池子也就算了如果是修改、删除呢怎么做到快速这里就涉及到你怎么管理 fd 的数据结构了。最常见的思路用 list 可以吗功能上可以但是性能上拉垮。list 的结构来管理元素时间复杂度都太高 O(n)每次要一次次遍历链表才能找到位置。池子越大性能会越慢。那有简单高效的数据结构吗有红黑树。Linux 内核对于 epoll 池的内部实现就是用红黑树的结构体来管理这些注册进程来的句柄 fd。红黑树是一种平衡二叉树时间复杂度为 O(log n)就算这个池子就算不断的增删改也能保持非常稳定的查找性能。现在思考第二个高效的秘密怎么才能保证数据准备好之后立马感知呢epoll_ctl 这里会涉及到一点。秘密就是回调的设置。在 epoll_ctl 的内部实现中除了把句柄结构用红黑树管理另一个核心步骤就是设置 poll 回调。思考来了poll 回调是什么怎么设置先说说file_operations-poll是什么Linux 设计成一切皆是文件的架构这个不是说说而已而是随处可见。实现一个文件系统的时候就要实现这个文件调用这个结构体用struct file_operations来表示。这个结构体有非常多的函数精简了一些如下struct file_operations { ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); __poll_t (*poll) (struct file *, struct poll_table_struct *); int (*open) (struct inode *, struct file *); int (*fsync) (struct file *, loff_t, loff_t, int datasync); // .... };你看到了readwriteopenfsyncpoll等等这些都是对文件的定制处理操作对于文件的操作其实都是在这个框架内实现逻辑而已比如 ext2 如果有对read/write做定制化那么就会是ext2_readext2_writeext4 就会是ext4_readext4_write。在open具体“文件”的时候会赋值对应文件系统的file_operations给到 file 结构体。那我们很容易知道read是文件系统定制 fd 读的行为调用write是文件系统定制 fd 写的行为调用file_operations-poll呢这个是定制监听事件的机制实现。通过 poll 机制让上层能直接告诉底层我这个 fd 一旦读写就绪了请底层硬件比如网卡回调的时候自动把这个 fd 相关的结构体放到指定队列中并且唤醒操作系统。举个例子网卡收发包其实走的异步流程操作系统把数据丢到一个指定地点网卡不断的从这个指定地点掏数据处理。请求响应通过中断回调来处理中断一般拆分成两部分硬中断和软中断。poll 函数就是把这个软中断回来的路上再加点料只要读写事件触发的时候就会立马通知到上层采用这种事件通知的形式就能把浪费的时间窗就完全消失了。划重点这个 poll 事件回调机制则是 epoll 池高效最核心原理。对于回调在什么时候触发可以看这篇文章epoll的实现原理-CSDN博客2.2小结划重点epoll 池管理的句柄只能是支持了file_operations-poll的文件 fd。换句话说如果一个“文件”所在的文件系统没有实现 poll 接口那么就用不了 epoll 机制。第二个问题poll 怎么设置在epoll_ctl下来的实现中有一步是调用vfs_poll这个里面就会有个判断如果 fd 所在的文件系统的file_operations实现了 poll 那么就会直接调用如果没有那么就会报告响应的错误码。static inline __poll_t vfs_poll(struct file *file, struct poll_table_struct *pt) { if (unlikely(!file-f_op-poll)) return DEFAULT_POLLMASK; return file-f_op-poll(file, pt); }epoll 的回调机制并不是由单一的“文件系统”或“网络协议栈”独立实现的而是内核中多个子系统协作的结果。具体来说文件系统VFS层面epoll 本身是一个文件描述符由eventpoll文件系统管理它通过 VFS 的poll接口file_operations-poll向被监听的文件描述符注册一个等待队列项。这个等待队列项中包含一个回调函数通常是ep_poll_callback。这一层提供了统一的“注册回调”框架。底层模块如网络协议栈层面当被监听的文件描述符例如 socket上有事件发生时如数据到达、连接建立等底层驱动如网络协议栈的 TCP 处理逻辑会唤醒该文件描述符的等待队列从而自动调用之前注册的ep_poll_callback函数。这个回调函数会将就绪事件加入 epoll 的就绪列表并唤醒等待在 epoll 上的进程。因此回调机制的“注册”依托于文件系统的 poll 机制而“触发”则依赖于底层模块网络协议栈、设备驱动等的事件处理。二者缺一不可。如果非要二选一通常认为epoll 的回调机制是基于文件系统VFS的 poll 接口实现的因为它是整个回调链路的起点和骨架而网络协议栈只是其中一类事件的提供者。你肯定好奇 poll 调用里面究竟是实现了什么总结概括来说挂了个钩子设置了唤醒的回调路径。epoll 跟底层对接的回调函数是ep_poll_callback这个函数其实很简单做两件事情把事件就绪的 fd 对应的结构体放到一个特定的队列就绪队列ready list唤醒 epoll 活来啦当 fd 满足可读可写的时候就会经过层层回调最终调用到这个回调函数把对应 fd 的结构体放入就绪队列中从而把 epoll 从epoll_wait出唤醒。这个对应结构体是什么结构体叫做epitem每个注册到 epoll 池的 fd 都会对应一个。就绪队列需要用很高级的数据结构吗就绪队列就简单了因为没有查找的需求了呀只要是在就绪队列中的 epitem 都是事件就绪的必须处理的。所以就绪队列就是一个最简单的双指针链表。小结下epoll 之所以做到了高效最关键的两点内部管理 fd 使用了高效的红黑树结构管理做到了增删改之后性能的优化和平衡epoll 池添加 fd 的时候调用file_operations-poll把这个 fd 就绪之后的回调路径安排好。通过事件通知的形式做到最高效的运行epoll 池核心的两个数据结构红黑树和就绪列表。红黑树是为了应对用户的增删改需求就绪列表是 fd 事件就绪之后放置的特殊地点epoll 池只需要遍历这个就绪链表就能给用户返回所有已经就绪的 fd 数组3哪些 fd 可以用 epoll 来管理再来思考另外一个问题由于并不是所有的 fd 对应的文件系统都实现了 poll 接口所以自然并不是所有的 fd 都可以放进 epoll 池那么有哪些文件系统的 file_operations 实现了 poll 接口首先说类似 ext2ext4xfs 这种常规的文件系统是没有实现的换句话说这些你最常见的、真的是文件的文件系统反倒是用不了 epoll 机制的。那谁支持呢最常见的就是网络套接字socket 。网络也是 epoll 池最常见的应用地点。Linux 下万物皆文件socket 实现了一套socket_file_operations的逻辑 net/socket.c static const struct file_operations socket_file_ops { .read_iter sock_read_iter, .write_iter sock_write_iter, .poll sock_poll, // ... };还有支持的吗有的很多。其实 Linux 下还有两个很典型的 fd 常常也会放到 epoll 池里。eventfdeventfd 实现非常简单故名思义就是专门用来做事件通知用的。使用系统调用eventfd创建这种文件 fd 无法传输数据只用来传输事件常常用于生产消费者模式的事件实现timerfd这是一种定时器 fd使用timerfd_create创建到时间点触发可读事件小结一下ext2ext4xfs 等这种真正的文件系统的 fd 无法使用 epoll 管理socket fdeventfdtimerfd 这些实现了 poll 调用的可以放到 epoll 池进行管理其实在 Linux 的模块划分中eventfdtimerfdepoll 池都是文件系统的一种模块实现。思考前面我们已经思考了很多知识点有一些简单有趣的知识点提示给读者朋友这里只抛砖引玉。问题单核 CPU 能实现并行吗不行。问题单线程能实现高并发吗可以。问题那并发和并行的区别是一个看的是时间段内的执行情况一个看的是时间时刻的执行情况。问题单线程如何做到高并发IO 多路复用呗今天讲的 epoll 池就是了。问题单线程实现并发的有开源的例子吗redisnginx 都是非常好的学习例子。当然还有我们 Golang 的 runtime 实现也尽显高并发的设计思想。总结IO 多路复用的原始实现很简单就是一个 1 对多的服务模式一个 loop 对应处理多个 fd IO 多路复用想要做到真正的高效必须要内核机制提供。因为 IO 的处理和完成是在内核如果内核不帮忙用户态的程序根本无法精确的抓到处理时机fd 记得要设置成非阻塞的哦切记epoll 池通过高效的内部管理结构并且结合操作系统提供的poll 事件注册机制实现了高效的 fd 事件管理为高并发的 IO 处理提供了前提条件epoll 全名 eventpoll在 Linux 内核下以一个文件系统模块的形式实现所以有人常说epoll 其实本身就是文件系统也是对的socketfdeventfdtimerfd 这三种”文件“fd 实现了 poll 接口所以网络 fd事件fd定时器fd 都可以使用 epoll_ctl 注册到池子里。我们最常见的就是网络fd的多路复用ext2ext4xfs 这种真正意义的文件系统反倒没有提供 poll 接口实现所以不能用 epoll 池来管理其句柄。那文件就无法使用 epoll 机制了吗不是的有一个库叫做 libaio 通过这个库我们可以间接的让文件使用 epoll 通知事件以后详说此处不表后记epoll 池使用很简洁但实现不简单。还是那句话Linux 内核帮你包圆了。今天并没有罗列太多源码实现以很小的思考点为题展开简单讲了一些 epoll 的思考以后有机会可以分享下异步IO aio 和 epoll 能产生什么火花Go 是怎样使用 epoll 池的敬请期待哦