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

难度 ★★★★★ · 前置:服务器并发:从 fork 到 epoll,四条路各自死在哪 | 一句话结论:select 和 poll 把「我要监听谁」和「谁就绪了」写在同一个结构里,所以每次调用都要重填重扫;epoll 把这两件事拆成两个系统调用——注册一次,之后只问结果。这就是全部差别。

这段 select 我抄过,两个坑都踩了

刚入职那会儿我要写一个内部的小网关,照着一个点赞很高的回答抄了一段 select 代码,编译跑起来一点毛病没有,就一直留在了仓库里。

1
2
3
4
5
6
7
8
9
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(listen_fd, &rfds);
for (int i = 0; i < nconn; i++) FD_SET(conns[i], &rfds);
int n = select(maxfd + 1, &rfds, NULL, NULL, NULL);
if (n < 0) { perror("select"); exit(1); }
if (FD_ISSET(listen_fd, &rfds)) do_accept();
for (int i = 0; i < nconn; i++)
if (FD_ISSET(conns[i], &rfds)) do_read(i);

看起来没什么问题,但它有两个隐藏的 bug,两个都不是我一眼能看出来的。第一个是 rfds 每次循环都得重新 FD_ZERO 加 FD_SET。select 会修改传进去的集合,返回的时候里面只剩就绪的 fd,如果这段代码放在 while(1) 里而你没有重填,第二轮就只监听上一轮就绪的那几个 fd 了,其他连接全部静默失效。第二个是 maxfd + 1 这个值容易过期。它本来是个缓存,每次新连接进来可能变大,连接关掉之后它不会自动变小,除非你主动重算,如果随手写成一个固定初值,那么新连接的 fd 号一旦超过它,那个连接永远不会被 select 看到。这两个 bug 都是 API 设计带来的,不是写代码的人不小心。(maxfd 那个线上跑了两周才炸,因为一开始连接少,fd 号一直没超过初值,后来压测并发一上来,所有新连接都不响应,我盯了一下午的日志才想到去看这个变量)epoll 把注册和查询拆成两个系统调用,从根本上躲掉了这两件事。

三个签名摆在一起

三个函数的签名,一眼就能看出差别。

1
2
3
4
5
6
7
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
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 的四个坑

  1. FD_SETSIZE = 1024,编译时定死。改它要重编 libc,而且内核的 select 实现仍是 O(n)。
  2. maxfd + 1 必须精确,少写了就漏 fd。
  3. select 返回后集合被毁,必须重建。
  4. timeout 也会被修改(Linux 上修改成剩余时间,某些实现不修改,这是可移植性雷区)。

poll 改掉了什么,没改掉什么

  1. 没有 1024 限制(用数组,长度自己定)。
  2. events(监听什么)和 revents(发生了什么)是分开的两个字段,所以不用重建数组,这是 poll 相对 select 最实在的改进。
  3. 但仍然要每次把整个数组拷进内核,仍然 O(n)。

epoll 也不是没有代价

  1. 不是更快,是每次调用更便宜,注册和注销有成本。
  2. ET 模式必须读到 EAGAIN。
  3. EPOLL_CTL_DEL 在 fd 被关闭时会自动发生,但如果 fd 被 dup 过,情况就变复杂了。
  4. epoll 实例本身也占一个 fd。

一个 fd_set 是 128 字节

fd_set 在 glibc 里就是这个结构,没什么玄机。

1
2
3
typedef struct {
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;

__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
2
3
4
5
struct pollfd {
int fd; // 监听谁
short events; // 我关心什么(POLLIN / POLLOUT ...)
short revents; // 实际发生了什么(内核填)
};

revents 是内核写的,events 是你写的,poll 返回之后只需要看 revents,events 原封不动。

1
2
3
4
5
6
7
8
9
10
struct pollfd fds[1024];
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
for (int i = 0; i < nconn; i++) {
fds[i + 1].fd = conns[i];
fds[i + 1].events = POLLIN;
}
int n = poll(fds, nconn + 1, -1);
for (int i = 0; i < nconn + 1; i++)
if (fds[i].revents & POLLIN) handle(fds[i].fd);

poll 相对 select 还有一个隐藏的改善,fd < 0 的项会被忽略,所以删一个连接可以简单地把 fds[i].fd 设成 -1,不用重排数组,代价是内核仍然要遍历这些空洞项。

epoll 用红黑树存注册,用链表存就绪

1
2
3
4
5
6
7
8
9
10
11
int ep = epoll_create1(EPOLL_CLOEXEC);

struct epoll_event ev = {
.events = EPOLLIN | EPOLLET,
.data.fd = cfd,
};
epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &ev);

struct epoll_event out[64];
int n = epoll_wait(ep, out, 64, -1);
for (int i = 0; i < n; i++) handle(out[i].data.fd);

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 里那两个结构

两个结构各司其职。红黑树负责注册,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
2
3
4
5
6
7
8
9
// ET 的正确读法
while (1) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) { handle(buf, n); continue; }
if (n == 0) { close_conn(fd); return; } // 对端 FIN
if (errno == EINTR) continue; // 被信号打断
if (errno == EAGAIN || errno == EWOULDBLOCK) return; // 正常:读干净了
close_conn(fd); // 真错误
}

EAGAIN 是正常退出,不是错误,这是 ET 代码里最容易被误判的地方,把它当错误处理会导致数据明明收到了却断连。推荐的组合是 EPOLLET 加非阻塞 fd 加上面这个循环,三者缺一不可。

另外两个开关:EPOLLONESHOT 和 EPOLLEXCLUSIVE

前面讲的都是单线程一把梭,一旦你想用多线程或者多进程来 accept、读同一个 fd,LT 和 ET 就不够用了,需要这两个标志。EPOLLONESHOT 是 2.6.2 加进来的,注册时带上它,这个 fd 触发一次之后内核自动把它从监听集合里摘掉,你得重新 epoll_ctl 一次 EPOLL_CTL_MOD 才能再收到事件。听起来麻烦,但它解决的正是同一个连接被两个线程同时读到的问题,线程 A 拿到事件、处理到一半,内核不会再把这个 fd 递给线程 B。

1
2
3
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
/* 处理完这一轮的读之后,重新武装 */
epoll_ctl(ep, EPOLL_CTL_MOD, fd, &ev);

记忆点是 EPOLLONESHOT 给一个 fd 一个线程用,不是给多个 fd 一个线程用,用它的时候每次处理完都要重新 arm,忘了 arm 就是这个连接再也不响应了。EPOLLEXCLUSIVE 是 4.5 加的,多个进程或线程各自 epoll_create、都去监听同一个 listen fd 时,默认行为是大家都被唤醒,也就是惊群,带上它之后内核只唤醒其中一个。这是 4.5 之后多进程 accept 的标准做法,也是 SO_REUSEPORT 之外的另一种选择。

1
2
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
epoll_ctl(ep, EPOLL_CTL_ADD, listen_fd, &ev); /* 每个 worker 各做一次 */

它和 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// poll_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <poll.h>
#include <arpa/inet.h>

#define MAXC 64

int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);

struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_addr.s_addr = INADDR_ANY;
a.sin_port = htons(9600);
bind(lfd, (struct sockaddr *)&a, sizeof a);
listen(lfd, 64);

struct pollfd fds[MAXC + 1];
fds[0].fd = lfd; fds[0].events = POLLIN;
for (int i = 1; i <= MAXC; i++) fds[i].fd = -1;

while (1) {
int n = poll(fds, MAXC + 1, -1);
if (n < 0) { perror("poll"); break; }

if (fds[0].revents & POLLIN) {
int c = accept(lfd, NULL, NULL);
for (int i = 1; i <= MAXC; i++)
if (fds[i].fd < 0) { fds[i].fd = c; fds[i].events = POLLIN; break; }
printf("accepted fd=%d\n", c);
}

for (int i = 1; i <= MAXC; i++) {
if (fds[i].fd < 0 || !(fds[i].revents & POLLIN)) continue;
char buf[4096];
ssize_t r = read(fds[i].fd, buf, sizeof buf);
if (r <= 0) {
close(fds[i].fd);
fds[i].fd = -1;
} else {
write(fds[i].fd, buf, r);
}
}
}
return 0;
}
1
2
3
4
5
6
# 先把上面那段完整存成 poll_echo.c
gcc -O2 poll_echo.c -o poll_echo # 先编完,别和启动写一块
./poll_echo &
sleep 1
printf 'hello\n' | nc -q1 127.0.0.1 9600 # -q 是 OpenBSD netcat 的选项,ncat/busybox nc 用 nc -N
kill %1

注意那个 fds[i].fd = -1,这就是 poll 里删连接的方式,不用重排数组。

换成 epoll,差的结构不在行数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ep_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <arpa/inet.h>

static void set_nonblock(int fd) {
int f = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, f | O_NONBLOCK);
}

int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);

struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_addr.s_addr = INADDR_ANY;
a.sin_port = htons(9700);
bind(lfd, (struct sockaddr *)&a, sizeof a);
listen(lfd, 1024);
set_nonblock(lfd);

int ep = epoll_create1(EPOLL_CLOEXEC);

struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd };
epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev);

struct epoll_event out[64];
while (1) {
int n = epoll_wait(ep, out, 64, -1);
for (int i = 0; i < n; i++) {
int fd = out[i].data.fd;
if (fd == lfd) {
while (1) {
int c = accept(lfd, NULL, NULL);
if (c < 0) break; // EAGAIN
set_nonblock(c);
struct epoll_event cev = { .events = EPOLLIN, .data.fd = c };
epoll_ctl(ep, EPOLL_CTL_ADD, c, &cev);
}
} else {
char buf[4096];
ssize_t r = read(fd, buf, sizeof buf);
if (r > 0) {
write(fd, buf, r);
} else if (r == 0) { // 对端关了
epoll_ctl(ep, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) {
// 真出错才断,EAGAIN 是「暂时没数据」,不能当错误
epoll_ctl(ep, EPOLL_CTL_DEL, fd, NULL);
close(fd);
}
}
}
}
return 0;
}
1
2
3
4
5
6
# 同样先存成 ep_echo.c
gcc -O2 ep_echo.c -o ep_echo
./ep_echo &
sleep 1
printf 'hello\n' | nc -q1 127.0.0.1 9700
kill %1

对比一下就清楚了,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
2
// et_bug.c,故意只读一次
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = cfd };

然后客户端一次发 10KB,服务端每次只 read(100),结果就是读了 100 字节之后再也不会被告知,剩下的 10140 字节永远卡在缓冲区里,修复办法就是在 read 外面加个 for(;;) 循环,一直读到 EAGAIN 或者返回 0 为止,前者表示缓冲区空了,后者表示对端关了连接,这两种情况才应该退出循环。这个实验我建议每个人都亲手跑一遍,因为只看文字很容易觉得自己懂了,真跑起来才会发现少读一次和读错一次的后果有多不对称,少读一次只是延迟,读错一次直接是数据丢失加连接悬挂。

select 改集合的现场证据

1
2
3
4
5
6
fd_set s;
FD_ZERO(&s);
FD_SET(0, &s);
select(1, &s, NULL, NULL, NULL);
printf("after select: FD_ISSET(0) = %d\n", FD_ISSET(0, &s));
// 在没有输入的情况下,打印 0

这就是必须重填的现场证据,不用信我说的,自己跑一遍就知道。

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 正好读到一条完整消息。