select、poll、epoll:三个接口,一次说清

select、poll、epoll:三个接口,一次说清
隔壁老康难度 ★★★★★ · 前置:服务器并发:从 fork 到 epoll,四条路各自死在哪 | 一句话结论:
select和poll把「我要监听谁」和「谁就绪了」写在同一个结构里,所以每次调用都要重填重扫;epoll把这两件事拆成两个系统调用——注册一次,之后只问结果。这就是全部差别。
这段 select 我抄过,两个坑都踩了
刚入职那会儿我要写一个内部的小网关,照着一个点赞很高的回答抄了一段 select 代码,编译跑起来一点毛病没有,就一直留在了仓库里。
1 | fd_set rfds; |
看起来没什么问题,但它有两个隐藏的 bug,两个都不是我一眼能看出来的。第一个是 rfds 每次循环都得重新 FD_ZERO 加 FD_SET。select 会修改传进去的集合,返回的时候里面只剩就绪的 fd,如果这段代码放在 while(1) 里而你没有重填,第二轮就只监听上一轮就绪的那几个 fd 了,其他连接全部静默失效。第二个是 maxfd + 1 这个值容易过期。它本来是个缓存,每次新连接进来可能变大,连接关掉之后它不会自动变小,除非你主动重算,如果随手写成一个固定初值,那么新连接的 fd 号一旦超过它,那个连接永远不会被 select 看到。这两个 bug 都是 API 设计带来的,不是写代码的人不小心。(maxfd 那个线上跑了两周才炸,因为一开始连接少,fd 号一直没超过初值,后来压测并发一上来,所有新连接都不响应,我盯了一下午的日志才想到去看这个变量)epoll 把注册和查询拆成两个系统调用,从根本上躲掉了这两件事。
三个签名摆在一起
三个函数的签名,一眼就能看出差别。
1 | int select(int nfds, fd_set *readfds, fd_set *writefds, |
select |
poll |
epoll |
|
|---|---|---|---|
| 传入什么 | 三个位图 | 一个数组 | 一个 epoll 实例 |
| 谁和「监听什么」绑定 | 位图的每一位 | 数组元素 | 在 epoll_ctl 里注册时 |
| 返回什么 | 就地改位图 | 就地改 revents |
另一个数组,只含就绪的 |
| 每次调用的成本 | O(maxfd) 拷贝 + 扫描 | O(n) 拷贝 + 扫描 | O(就绪数) |
| fd 上限 | 1024 | 无限 | 无限 |
我第一次读这几个函数的时候,最想问的是为什么非得有三个。
就地修改这四个字是理解 select 和 poll 的关键,你的输入会被内核改成输出,所以每次都得重来一遍。epoll 没有这个问题,输入在 epoll_ctl 里,输出在 epoll_wait 的 events 数组里,两者不冲突。(我第一次读这三个函数的时候觉得 epoll_ctl 是多余的,心想注册一次和每次传进去能有什么区别,后来才明白这正好把那次拷贝从热路径上挪走了,连接一万个、每秒调用十万次,省下来的就是这个数)
select 的四个坑
FD_SETSIZE= 1024,编译时定死。改它要重编 libc,而且内核的select实现仍是 O(n)。maxfd + 1必须精确,少写了就漏 fd。select返回后集合被毁,必须重建。timeout也会被修改(Linux 上修改成剩余时间,某些实现不修改,这是可移植性雷区)。
poll 改掉了什么,没改掉什么
- 没有 1024 限制(用数组,长度自己定)。
events(监听什么)和revents(发生了什么)是分开的两个字段,所以不用重建数组,这是poll相对select最实在的改进。- 但仍然要每次把整个数组拷进内核,仍然 O(n)。
epoll 也不是没有代价
- 不是更快,是每次调用更便宜,注册和注销有成本。
- ET 模式必须读到
EAGAIN。 EPOLL_CTL_DEL在 fd 被关闭时会自动发生,但如果 fd 被dup过,情况就变复杂了。epoll实例本身也占一个 fd。
一个 fd_set 是 128 字节
fd_set 在 glibc 里就是这个结构,没什么玄机。
1 | typedef struct { |
__FDSET_LONGS 通常是 16,也就是 1024 除以 64,所以一个 fd_set 是 128 字节。FD_SET(fd, set) 展开成这么一行位运算:
1 | set->__fds_bits[fd / 64] |= (1UL << (fd % 64)) |
所以 fd 90 落在第 1 个 64 位字的第 26 位。select 每次调用要把这 128 字节从用户态拷到内核态,内核遍历 nfds 位,返回之后再拷回来,1000 个连接就是每次 16 次拷贝加 2 次扫描。这也是为什么 nfds 这个参数存在,内核只需要看前 nfds 位,传大了白扫,传小了漏 fd。所以在连接稀疏的场景里 nfds 通常设成当前最大 fd 加一,看着很省,实际上省下的那点扫描量跟后面那次整体拷贝比根本不算什么,真正吃时间的是拷贝本身,一块 128 字节的内存在 L1 里也就一两个周期,可它过了一次权限边界,从此不再享受寄存器优化的待遇。
poll 把 events 和 revents 拆成两个字段
1 | struct pollfd { |
revents 是内核写的,events 是你写的,poll 返回之后只需要看 revents,events 原封不动。
1 | struct pollfd fds[1024]; |
poll 相对 select 还有一个隐藏的改善,fd < 0 的项会被忽略,所以删一个连接可以简单地把 fds[i].fd 设成 -1,不用重排数组,代价是内核仍然要遍历这些空洞项。
epoll 用红黑树存注册,用链表存就绪
1 | int ep = epoll_create1(EPOLL_CLOEXEC); |
data 是个 union,里面有 void *ptr、int fd、uint32_t u32、uint64_t u64,这意味着你可以直接挂指针,省掉一次 fd 到连接对象的映射。很多高性能框架正是这么做的。
1 | ev.data.ptr = my_conn; // 直接挂连接对象 |
epoll_ctl 有四个 op,ADD、MOD(改事件)、DEL,还有 EPOLL_CTL_DEL 的一个特殊行为,如果 fd 已经关闭,DEL 会返回 EBADF,但内核其实已经自动清掉了。
eventpoll 里那两个结构
graph TD
A["epoll_create1<br/>→ fd 编号"] --> B["eventpoll 对象"]
B --> C["红黑树 rbr<br/>存所有注册的 fd"]
B --> D["就绪链表 rdllist<br/>存已就绪的 fd"]
C -->|"新数据到达"| D
D -->|"epoll_wait 取走"| E["用户数组 events[]"]
两个结构各司其职。红黑树负责注册,epoll_ctl 增删是 O(log n),这就是注册一次的代价,就绪链表负责交付,数据到达时内核的回调 ep_poll_callback 把该 fd 挂上去,epoll_wait 从链表里取,复杂度是 O(就绪数)。注意图里那条从红黑树指向就绪链表的箭头,它画在新数据到达上,意思是就绪链表的填充不发生在 epoll_wait 里,而发生在网络数据包到达时的软中断上下文里,这就是 epoll 和 poll 最本质的差别。
LT 和 ET,差在会丢数据上
LT 是水平触发,内核把 fd 挂上就绪链表之后,如果 epoll_wait 取走了但你没读完,下次它还会挂回来。ET 是边缘触发,内核只在缓冲区从空变成非空的那一次挂上去,取走就没了,直到下一次状态变化。所以 ET 的规矩只有一条,你必须一次读到 EAGAIN。这条规矩看着简单,实践里错得最多的地方是中间断开,比如循环里读了几轮之后碰到一个非阻塞 fd 返回 EWOULDBLOCK,很多人在这里 break 出去,以为相当于 EAGAIN,其实只要读取条件变成“暂时没数据”而不是“真的读完”,正确性就还在,问题出在有人把 EINTR 也当成了结束条件,信号一扰动就提前退出,剩下的数据再也不会通知你,这种 bug 只在系统负载高的时候偶发,压测跑不出来。
1 | // ET 的正确读法 |
EAGAIN 是正常退出,不是错误,这是 ET 代码里最容易被误判的地方,把它当错误处理会导致数据明明收到了却断连。推荐的组合是 EPOLLET 加非阻塞 fd 加上面这个循环,三者缺一不可。
另外两个开关:EPOLLONESHOT 和 EPOLLEXCLUSIVE
前面讲的都是单线程一把梭,一旦你想用多线程或者多进程来 accept、读同一个 fd,LT 和 ET 就不够用了,需要这两个标志。EPOLLONESHOT 是 2.6.2 加进来的,注册时带上它,这个 fd 触发一次之后内核自动把它从监听集合里摘掉,你得重新 epoll_ctl 一次 EPOLL_CTL_MOD 才能再收到事件。听起来麻烦,但它解决的正是同一个连接被两个线程同时读到的问题,线程 A 拿到事件、处理到一半,内核不会再把这个 fd 递给线程 B。
1 | ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT; |
记忆点是 EPOLLONESHOT 给一个 fd 一个线程用,不是给多个 fd 一个线程用,用它的时候每次处理完都要重新 arm,忘了 arm 就是这个连接再也不响应了。EPOLLEXCLUSIVE 是 4.5 加的,多个进程或线程各自 epoll_create、都去监听同一个 listen fd 时,默认行为是大家都被唤醒,也就是惊群,带上它之后内核只唤醒其中一个。这是 4.5 之后多进程 accept 的标准做法,也是 SO_REUSEPORT 之外的另一种选择。
1 | ev.events = EPOLLIN | EPOLLEXCLUSIVE; |
它和 SO_REUSEPORT 的区别在于,EPOLLEXCLUSIVE 是多个 epoll 实例共享同一个 listen socket,SO_REUSEPORT 是每个 worker 各有一个 listen socket、内核在 socket 层面分流,前者兼容老代码,后者分流更均匀还支持热重启,代价是内核版本要求更高,要 3.9 以上。两个标志都别和 EPOLLET 随便组合,EPOLLEXCLUSIVE 和 EPOLLET 一起用在 listen fd 上没有意义,listen fd 本来就不会有读了一半的问题。
发十个字节,只读一个
一个很简单的实验,建立一个连接,客户端发 10 字节,服务端只 read(1) 一个字节。LT 模式下每次 epoll_wait 都返回这个 fd,直到 10 字节读完为止;ET 模式下只返回一次,读走 1 字节后不再返回,剩下 9 字节留在缓冲区里,永远不会被告知。这就是 ET 事件的一次机会性质。
分界点不在连接数上
| 并发 | 建议 |
|---|---|
| < 100 | 随便,poll 最省事 |
| 100 ~ 1000 | poll 依然够用,epoll 更稳 |
| > 1000 | epoll |
接着说这个阈值,它比大多数人想的要模糊。
epoll 在连接数少的时候甚至比 poll 慢,因为多了一层间接,红黑树加链表,这个慢大概是微秒级,不影响任何真实业务。真正该换 epoll 的信号是 strace -c 显示 poll 占了 CPU 的显著比例,而不是连接数超过某个数。(这个结论是同事提醒我看的,我原来一直按连接数拍脑袋,后来每次面试有人问阈值我都说不出所以然,就是没看过 strace)
两个 echo,一段 strace
先跑一个 poll 版。
1 | // poll_echo.c |
1 | # 先把上面那段完整存成 poll_echo.c |
注意那个 fds[i].fd = -1,这就是 poll 里删连接的方式,不用重排数组。
换成 epoll,差的结构不在行数
1 | // ep_echo.c |
1 | # 同样先存成 ep_echo.c |
对比一下就清楚了,poll 版本每次调用都要把整个数组拷进内核、并线性扫描一遍,包括那些 fd = -1 的空洞,epoll 版本注册一次、只等就绪的,这就是全部差别的具象化。两个版本的行数差不多,差的不是一个语法糖,是每次调用的工作量。量级上的差别在连接数上到万的时候才显出来,十个八个连接时你压根感觉不到,甚至 epoll 还略慢一点,因为它多了一次 epoll_ctl 的注册开销,而 poll 那一轮扫描对十个 fd 来说也就是几十个纳秒的事,所以别在小服务上硬套 epoll,把它留给真正需要它的量级。
strace 看到的单次耗时
1 | // 只是让进程阻塞在 poll 上 |
1 | timeout 5 strace -c -p $(pgrep -o poll_echo) # -p 后面不能再跟命令 |
strace -c 统计的是调用次数乘以单次时间,压力不同差异很大,更直接的办法是看系统调用的耗时分布。
1 | strace -T -e trace=poll,epoll_wait ./poll_echo 2>&1 | head -5 |
尖括号里的数字就是单次耗时,空载时两者都在微秒级。
造一个 ET 丢数据的例子
1 | // et_bug.c,故意只读一次 |
然后客户端一次发 10KB,服务端每次只 read(100),结果就是读了 100 字节之后再也不会被告知,剩下的 10140 字节永远卡在缓冲区里,修复办法就是在 read 外面加个 for(;;) 循环,一直读到 EAGAIN 或者返回 0 为止,前者表示缓冲区空了,后者表示对端关了连接,这两种情况才应该退出循环。这个实验我建议每个人都亲手跑一遍,因为只看文字很容易觉得自己懂了,真跑起来才会发现少读一次和读错一次的后果有多不对称,少读一次只是延迟,读错一次直接是数据丢失加连接悬挂。
select 改集合的现场证据
1 | fd_set s; |
这就是必须重填的现场证据,不用信我说的,自己跑一遍就知道。
ulimit -n 得先调
1 | ulimit -n |
1024 是很多镜像的默认值,上 epoll 之前先把这里调大,ulimit -n 1048576 或者改 /etc/security/limits.conf。不要指望 epoll 绕过 fd 限制,它只是让轮询变便宜了,fd 该占还是占。
这几条我当初全判断反了
epoll 一定比 poll 快。这句话在连接少的时候是反的,epoll 反而略慢,因为多一层红黑树,它的优势只在海量连接、少量活跃时体现。epoll_wait 返回就说明能读到数据。不绝对,对端 close 之后 epoll_wait 也会返回 EPOLLIN,但 read() 返回 0,所以必须处理 r == 0 的情况。(这条我踩过,read 返回 0 被我当成错误直接 close,日志里全是没有原因的断连,查了两天才发现是正常关闭)ET 模式能减少系统调用所以一定更好。省的是 epoll_wait 的返回次数,但你必须多写一个 while 循环把数据读干净,系统调用总数其实差不多,ET 的真实收益在避免不必要的唤醒,不在减少 read。
select 被淘汰了。有些场景仍然用它,Windows 下的 select 是唯一跨平台的,因为 WSAPoll 有 bug,跨平台库比如 libuv 在 Linux 上用 epoll、macOS 用 kqueue、Windows 用 select。(这张对照表我是从 libuv 源码的注释里看到的,当时还挺震惊,Windows 上居然真的没有更好的选择)epoll 的 data 只能放 fd。它是 union,可以放指针,data.ptr 存连接对象是大多数高性能框架的做法。一定要用 epoll_create1 而不是 epoll_create。对,epoll_create 的 size 参数从 2.6.8 起就没用了,而且没法设 EPOLL_CLOEXEC,用 epoll_create1(EPOLL_CLOEXEC)。fd 关了之后要 EPOLL_CTL_DEL。可以不删,内核会自动清理,但如果你 dup 过同一个文件,不同 fd 号指向同一个 struct file,那条 epoll 记录会一直存在到最后一个 fd 关闭,这是经典泄漏。epoll_wait 的 maxevents 越大越好。它只是一次最多返回多少,设太大会浪费内存,设太小要多轮,64 到 1024 是常见值。
差别只有一句话
select/poll把「监听谁」和「谁就绪」写在同一结构里,所以每次调用都要重填重扫;epoll拆成epoll_ctl(注册一次)+epoll_wait(只取结果)。select的FD_SETSIZE=1024是编译时常量;poll用数组,没有上限,且events/revents分离。maxfd + 1和「集合被就地修改」是select的两个经典 bug 来源。epoll内部是红黑树(注册)+ 就绪链表(已就绪),epoll_wait的复杂度是 O(就绪数)。- LT 安全,ET 高效。ET 必须循环读到
EAGAIN,且必须配非阻塞 fd。 epoll_wait返回可读但read()返回 0 是正常关闭,不是错误。< 1000并发用poll完全没问题;换epoll的信号是strace -c里poll占比高,而不是某个连接数。- 上
epoll之前先ulimit -n,fd 限制不会因为换了模型而消失。
还有件事是我当初没预料到的。
写完这两个 echo 之后我以为等数据这件事已经讲完了,后来才发现麻烦在后面,数据确实到了,但它不认识你的消息边界。TCP 只保证字节流有序,不保证一次 read 正好读到一条完整消息。











