两年前我接手一个内部服务,原方案用的是“每来一个连接就开一个线程”的模式。连接数少的时候还好,等并发跑到三百多,线程频繁创建销毁,CPU 飙高,内存涨得飞快,最后进程直接僵死。当时我花了一个晚上把核心模块改成 epoll 驱动的 TCP 并发服务器,同一台机器,压到三千连接依旧稳得很,CPU 占用还降了一半。从那以后,凡是要做高并发 TCP 服务,我第一个想到的方案就是 epoll。
这篇文章会把 epoll 的原理、核心 API、触发模式、完整代码以及我在实际调试中踩过的坑从头到尾过一遍。适合刚接触 Linux 网络编程的开发者,也适合在传统多线程模型上吃够了苦头、想换事件驱动思路的工程师。你可以直接照着代码改,也可以只读设计思路,然后迁移到自己的项目里。
1. 并发服务器模型:从阻塞I/O到epoll事件驱动
1.1 经典模型的痛点在哪里
先看学校里最常教的“多线程 TCP 服务器”。主线程 accept 一个连接,就创建一个线程去处理这个连接的读写。逻辑很直接,但对高并发场景来说问题非常明显。
第一,线程是有成本的。创建一个线程默认栈空间是 8MB(虚拟内存),虽然实际物理内存按需分配,但上千个线程光上下文切换就能让 CPU 疲于奔命。第二,阻塞 I/O 会浪费 CPU。线程在 recv 等数据的时候是阻塞挂起的,这个等待期间 CPU 什么都没干。连接越多,浪费越严重。
有人会说,那我用非阻塞 I/O + 轮询行不行?轮询没问题,但在应用层对每个连接循环调用 recv,大部分调用都会返回 EAGAIN,这是典型的用户态空转,白白消耗 CPU。
select 和 poll 的出现解决了一部分问题,但它们的核心缺陷依然存在:每次调用都要把全部 fd 集合从用户态拷贝到内核态,内核再线性扫描一遍找出就绪的 fd,扫描完再拷贝回用户态。连接数一多,这个 O(n) 的拷贝加扫描就成了瓶颈。
1.2 select和poll的具体瓶颈
select 有个硬限制,FD_SETSIZE 默认是 1024,也就是说最多监听 1024 个 fd。虽然可以改宏重新编译内核,但本质上还是线性扫描的方式,治标不治本。
poll 去掉了 1024 的限制,用 pollfd 数组来管理 fd。但它依然存在两个问题:
- 每次调用 poll 时,要把全部 fd 从用户态拷贝到内核态,调用结束后再拷贝回用户态。假设有一万个连接,其中只有两个就绪,那这一万次拷贝里百分之九十九点九都是无用功。
- 内核在 poll 内部也是遍历整个 fd 数组来检查每个 fd 的状态。连接越多,遍历越慢。
你可以把 select/poll 想象成一个客服,每天上班第一件事就是挨个打电话问一百个客户“你有事吗”,问完一圈只有两个客户说有事。这个效率可想而知。
1.3 epoll的机制为什么高效
epoll 的思路完全不同。它把“我要监听哪些 fd”提前告诉内核,内核维护一个事件表。当某个 fd 上有事件发生时,内核通过回调机制把对应的 fd 加入到就绪链表。调用 epoll_wait 时,只需要把就绪链表里的 fd 拷贝到用户态就行。
也就是说,epoll 在“告诉我哪些 fd 有事件”这一步做到了 O(1),而不是 O(n)。内核在底层通过红黑树管理需要监听的 fd,增删改查都是对数级别,即使连接数量很大也能扛住。
另外一个关键点,epoll 不像 select/poll 那样每次调用都要重建监听列表。fd 集合在内核中持续存在,只有调用 epoll_ctl 增删改时才需要更新。这种设计天然适合“连接只在某几个时间点建立和断开,大多数时间都在等待读写”的服务器场景。
用一句话来记忆:select 是“每次我都问你一遍”,epoll 是“你有事再通知我”。这就是事件驱动和轮询的本质区别。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. epoll核心API与两种触发模式
2.1 三个核心函数
epoll 的使用围绕三个函数展开。
epoll_create1 用来创建一个 epoll 实例,返回一个文件描述符。早期版本是 epoll_create(int size),size 参数被忽略了但必须大于 0。新版建议直接用 epoll_create1(0),如果加上 EPOLL_CLOEXEC 标志,可以在 exec 时自动关闭这个 fd,避免子进程意外继承。
c复制int epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
epoll_ctl 用来对 epoll 实例做增删改操作。
c复制struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = client_fd;
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
三个操作类型要记清楚:EPOLL_CTL_ADD 添加一个新的 fd 到监听表;EPOLL_CTL_MOD 修改一个已有 fd 的关注事件;EPOLL_CTL_DEL 把一个 fd 从监听表中移除。
epoll_wait 是阻塞等待事件发生的函数。
c复制struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
// 处理 events[i]
}
timeout 参数是超时毫秒数,-1 表示无限等待。返回值 n 是本次就绪的事件个数,遍历 events 数组前面的 n 项即可。
2.2 LT和ET到底有什么区别
LT 是水平触发,是默认模式;ET 是边缘触发,需要在注册事件时加上 EPOLLET 标志。
用门铃来类比:门铃响了一次,你没有开门。LT 模式下,只要门铃事件没有处理完,门铃会一直响,提醒你“还有事没处理”;ET 模式下,门铃只在状态变化的那一刻响一次,如果你没处理,之后不会再响,除非状态再次变化。
具体到代码层面:
- LT 模式下,只要 socket 接收缓冲区里有数据,epoll_wait 就会一直返回 EPOLLIN 事件。这种模式不容易丢数据,比较适合新手。
- ET 模式下,只有当缓冲区从无数据变成有数据的那一刻,epoll_wait 才会返回一次 EPOLLIN 事件。如果你只 read 了一次,缓冲区可能还剩数据,但内核不会再通知你了,必须一次性把所有数据读完,也就是循环 read 直到返回 EAGAIN。
ET 模式的优点是不会频繁触发,性能更高;缺点是要求程序员对事件处理非常严谨,少读一次就丢数据。
我的经验是,刚开始用 epoll 时先用 LT,代码简单、不容易出错。等项目跑通、理解了数据流,再切换到 ET 模式做优化。生产环境我最终用的是 ET 模式,但前提是代码里对读循环处理得非常仔细。
2.3 需要关注的事件类型
epoll_event 的 events 字段可以组合多种事件,最常用的有几个:
- EPOLLI
