如果你用原生 socket 写过服务器,多半见过这个场景:服务器accept了一个客户端之后,如果没有额外写并发逻辑,它就会一直阻塞在read/recv那里干等数据,第二个客户端想连进来也只能排在后面。这是阻塞 IO 最直接的代价。要突破这个瓶颈,第一步就是搞懂高级 IO 模型与多路转接,也就是select、poll这套工具。它们不只在《计算机网络》课程里是重点,在 408 统考、公司面试、以及真实服务器开发里同样是绕不开的基础。
很多复习提纲只会让你背“select 有 1024 限制、poll 没有上限、epoll 效率更高”,靠这个应付选择填空没问题,但真到让你写一个能同时服务几百个连接的 echo server,或者排查线上 fd 泄漏问题时,你会发现光背结论远远不够。这篇文章不打算复述教材里的原话,我会从阻塞 IO 的痛点开始,把五种 IO 模型逐一拆开,再把 select 和 poll 的实现细节、使用姿势、典型坑点讲透,最后聊到它们和 epoll 的本质差异。
1. 先看一个卡死的服务器:阻塞 IO 的代价有多高
1.1 从最原始的迭代服务器说起
第一次接触 socket 编程时,很多人都会写出类似的代码:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); bind(listen_fd, ...); listen(listen_fd, 16); while (1) { int conn_fd = accept(listen_fd, NULL, NULL); // 处理客户端请求 char buf[1024]; read(conn_fd, buf, sizeof(buf)); // ... close(conn_fd); }这个程序最大的问题不在代码本身,而在于read(conn_fd, ...)是阻塞调用。当某个客户端连接上来之后,如果它一直不发数据,服务端进程就会在read上睡着,后面再来的连接请求根本不会被accept到。
更隐蔽的是,accept本身也会阻塞。只要有客户端连进来,哪怕它一句话不说,服务端也会卡在“处理这个连接”的逻辑上。如果你还试过把listen()的 backlog 调大,会发现连接队列确实能暂存一部分请求,但队列满之后,新的连接请求照样会被丢掉或者被拒绝。
1.2 阻塞点不只是 read,accept 也会卡
很多人以为把“单连接处理”改成“每来一个客户端就开一个线程”就解决了。确实能解决“阻塞 read 影响其他客户端”的问题,但代价也很明显:
- 线程本身有内存开销。Linux 下线程栈默认是 8MB 的虚拟内存,虽然物理内存是按需分配,但并发几千个线程时,光是栈空间和线程控制块就已经非常可观。
- 线程切换有成本。大量线程同时就绪时,CPU 需要频繁保存和恢复上下文,真正的业务逻辑没跑多少,时间全耗在线程调度上。
- 锁和同步问题会变得复杂。多个线程同时操作共享数据结构,要么加锁,要么用无锁结构,调试难度成倍上升。
所以“一个连接一个线程”这种模型,在几十个连接时还能凑合,到几千、上万个连接时基本就撑不住了。这也是为什么大家会说 C10K 问题:单机能不能同时维护一万个连接,靠的从来不是无限开线程,而是换个思路,让一个线程同时监控大量连接。
1.3 高级 IO 模型要解决的核心命题
观察一下阻塞 IO 的本质:程序想在某个 fd 上读数据,但不知道数据什么时候到,只能把整个执行流挂起。如果这个 fd 是唯一的,那没问题;可服务器要面对的是几十上百个 fd,你不能为每个 fd 都停在那里等。
于是问题就变成了:有没有一种办法,让进程一次阻塞就能等来多个 fd 中任意一个就绪,然后告诉你“哪些 fd 可以读了、哪些 fd 可以写了”?答案是肯定的,这就是多路转接模型。select、poll、epoll都是这个思路下的具体实现。
搞懂这些工具之前,得先把五种 IO 模型的整体图景看清楚,因为很多人分不清 select 到底处于什么位置,也容易把“非阻塞 IO”和“IO 多路复用”混为一谈。
2. 五种 IO 模型逐个过:阻塞、非阻塞、多路复用、信号驱动、异步到底差在哪
2.1 先建立一个简单的两阶段模型
所有 IO 操作,尤其是网络 IO,都可以拆成两个阶段:
- 等待数据就绪:数据从网卡到达内核缓冲区,或者连接上出现某种事件。
- 拷贝数据:把数据从内核缓冲区复制到用户空间的缓冲区,或者把用户数据复制到内核发送缓冲区。
这个两阶段模型是理解五种模型的钥匙。阻塞和非阻塞、同步和异步,最本质的区别就是在这两个阶段上,进程是否被阻塞、数据由谁来拷贝、什么时候通知。
2.2 用“点餐等外卖”把五种模型讲明白
这五种模型的名字特别劝退,我用一个日常场景类比一下:
- 阻塞式 IO:你站在取餐口一直盯着后厨,饭没做好你就不走。这期间你没法干别的事,直到饭端出来你才端着走。
- 非阻塞式 IO:你每隔几秒跑去问一次“好了吗”,没好就先回座位刷手机。但这样来回跑很累,而且你无法确定下一次该隔多久来问一次。
- IO 多路复用:你在店里取了个号,然后在休息区睡觉等叫号。叫号屏会显示你的号,你醒了去窗口取餐。注意,叫号只是通知“可以取餐了”,取餐这个动作还是你自己做的。
- 信号驱动 IO:取号时留下手机号,饭好了店员给你打电话。你接到电话再去取餐。难点在于,如果你同时点了很多家外卖、留了很多电话,每次铃声响起,你都要接起来判断到底是哪家的。
- 异步 IO:你直接点外卖,由骑手送到家门口。你全程不用去取,饭到了系统会提示“可以开吃了”。
网络 IO 里的“取餐”,说白了就是 read/recv 从内核缓冲区把数据拷贝到用户缓冲区。select、poll、epoll 属于第 3 种,它们只负责帮你盯着几十个 fd,告诉你哪个“叫号”了,真正执行 read 的还是应用程序自己。因为这最后一步仍然由程序同步完成,所以严格说它们属于同步 IO 多路转接,不算异步 IO。
2.3 非阻塞 IO 单独用没意义,它是多路复用的好搭档
很多人学完非阻塞 IO 之后很兴奋:把 socket 设成O_NONBLOCK,然后read没数据就立刻返回,这不是很高效吗?
但仔细想想,程序在read返回EAGAIN之后干什么?如果继续循环调用read,那就是忙轮询,CPU 会被白白烧掉;如果放着不管,那和没监听也没区别。所以单独使用非阻塞 IO 的价值很有限,它真正的价值在于配合 select/poll/epoll:
- 当 select 告诉我们某个 fd 可读时,我们调用
read; - 因为已经设成非阻塞,即使发生竞争导致数据被别的线程读走了,
read也会立刻返回EAGAIN,而不会让线程无限期睡过去。
因此在写高并发服务器时,常见的组合是:select/poll/epoll 负责等待 + 非阻塞 socket 负责实际读写。
2.4 别再把同步/异步和阻塞/非阻塞混为一谈
这两个维度是独立的:
- 阻塞/非阻塞,描述的是调用者在调用结果还没准备好时,是否原地等待。
- 同步/异步,描述的是“数据从内核到用户空间的拷贝”由谁完成、应用什么时候被通知。
所以非阻塞忙轮询虽然调用没有阻塞,但数据拷贝仍然要靠自己反复尝试去完成,本质上还是同步行为。异步 IO 的标志是:你发起一个aio_read或者用 io_uring、Windows 的 IOCP,系统会在数据拷贝到你的用户缓冲区之后才通知你,你完全不需要自己调 read。
把 epoll 说成“异步 IO”是流传很广的误区。epoll 只是告诉你“这个 fd 可读了”,数据还在内核缓冲区里,你还得自己调用 read 去取,所以它仍然是同步模型,只是它的同步等待对象从“单个 fd”变成了“多个 fd 的就绪状态”。
3. select 调用约定与事件循环骨架,以及三个挥之不去的痛点
3.1 select 的函数签名与 fd_set 位图
select的原型长这样:
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);nfds:所有需要监视的文件描述符中的最大值再加 1。这个“加 1”很多人会忘,因为内核要遍历从 0 到 nfds-1 的所有 fd。readfds:监听“可读”事件的 fd 集合。writefds:监听“可写”事件的 fd 集合。exceptfds:监听异常条件的 fd 集合,比如 TCP 带外数据。timeout:超时时间。传NULL表示永久阻塞;传一个值为 0 的timeval表示只做一次非阻塞检查;其他情况表示最多等待多久。
fd_set本质上是一个位图,FD_SETSIZE编译期常量决定它最多能容纳多少位。操作 fd_set 必须用下面的宏,不能直接给结构体赋值或按位操作:
FD_ZERO(&set); // 清空集合 FD_SET(fd, &set); // 把 fd 加入集合 FD_CLR(fd, &set); // 把 fd 从集合中移除 FD_ISSET(fd, &set); // 判断 fd 是否在集合中有个非常阴险的细节:select返回时,内核会把readfds/writefds/exceptfds这 3 个集合直接改掉,只保留“就绪”的 fd。所以你想在下一次循环继续监听同样一批 fd,就必须在每次调用前重新构造集合,这就是为什么教科书代码里总会出现一个“备份 fd_set”。
3.2 一个 select 事件循环的骨架
一段比较典型的 select 服务器骨架长这样:
int max_fd = listen_fd; fd_set all_set, ready_set; FD_ZERO(&all_set); FD_SET(listen_fd, &all_set); while (1) { ready_set = all_set; // 重新拷贝,这是必须的 int nready = select(max_fd + 1, &ready_set, NULL, NULL, NULL); if (nready < 0) { if (errno == EINTR) // 被信号打断,重新调用 continue; perror("select"); break; } // 1. 先处理新连接 if (FD_ISSET(listen_fd, &ready_set)) { int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd >= 0) { FD_SET(conn_fd, &all_set); if (conn_fd > max_fd) max_fd = conn_fd; set_nonblocking(conn_fd); } if (--nready <= 0) continue; } // 2. 再处理每个客户端 for (int i = 0; i <= max_fd && nready > 0; i++) { if (!FD_ISSET(i, &ready_set)) continue; char buf[1024]; ssize_t n = recv(i, buf, sizeof(buf), 0); if (n > 0) { send(i, buf, n, 0); } else if (n == 0) { // 对端关闭 close(i); FD_CLR(i, &all_set); if (i == max_fd) while (max_fd > 0 && !FD_ISSET(max_fd, &all_set)) max_fd--; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) continue; // 非阻塞模式下数据已被读走 close(i); FD_CLR(i, &all_set); if (i == max_fd) while (max_fd > 0 && !FD_ISSET(max_fd, &all_set)) max_fd--; } nready--; } }这里每一步都有讲究。accept到新连接后要立刻放进all_set,同时更新max_fd;每次循环要把all_set拷给ready_set,因为ready_set会被内核改写;recv返回 0 表示对端正常关闭,如果不 close 也不从集合里清除,fd 会泄漏。
3.3 select 的三个固有痛点,不是使用姿势的问题
select 的痛点不是写得不够小心就能解决的,它们内生于 API 设计:
- fd 上限受编译期限制:
FD_SETSIZE在绝大多数 Linux 环境下默认是 1024。并不是说整个系统只能开 1024 个文件描述符,而是说 select 单次调用能同时监视的 fd 数量被fd_set位图大小限制死了。超过这个数字再用FD_SET往里放,轻则被忽略,重则直接越界写坏内存。 - 每次调用都要把 fd 集合在内核态和用户态之间全量拷贝:哪怕你这轮只有一个 fd 就绪,select 也要把所有注册过的 fd_set 拷进来、再拷出去。fd 数量大到一定程度,这个拷贝开销非常可观。
- 内核和应用层都是 O(n) 线性扫描:内核需要从 0 到
nfds-1遍历一遍,去看每个 fd 是否有事件;select 返回后,用户代码也要遍历一遍,才知道到底是哪个 fd 就绪了。注意,这里的 n 不是你“活跃的连接数”,而是你“注册监听的最大 fd 数”。当连接数很大、但活跃连接很少时,大量 CPU 时间会浪费在扫描上。
另外,timeout参数在某些平台也会被内核修改。Linux 在超时返回时会把剩余时间写回timeval,所以循环里如果复用了同一个timeval变量,必须重新赋值。我见过有人在 while 循环外面定义一次 timeout,结果第一次超时后 timeout 变成 0,后来 select 每次都是非阻塞轮询,把 CPU 跑满,查了半天才定位到。
4. poll 的改进与天花板:events/revents 分离并不等于高效
4.1 pollfd 的三个字段:输入事件和返回事件分开
poll 的接口明显比 select“现代”一点:
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; // 要监听的文件描述符 short events; // 输入:我关心的事件 short revents; // 输出:实际发生的事件 };关键改进在于events和revents分开了。调用 poll 之前,你设置events;poll 返回后,内核只修改revents,不会动events。这意味着如果监听集合没有变化,你不需要像 select 那样每次重新拷贝一份“模板”集合,同样一批 pollfd 可以直接反复传入。这一点在事件驱动框架里省了不少事。
同时 poll 没有编译期固定上限。nfds是nfds_t类型,你可以传入一个很大的数组,只要每个 fd 都没超过进程的RLIMIT_NOFILE限制即可。这直接解决了 select 的 1024 问题。
4.2 常用事件掩码与“异常事件不请自来”
poll 的事件掩码常见的是:
POLLIN:有数据可读,或者对端关闭了连接(read 不会阻塞且可能返回 0)。POLLOUT:可写。TCP 发送缓冲区有足够空间时就会返回。POLLPRI:有紧急数据,对应 TCP 带外数据。POLLERR:发生错误。POLLHUP:挂起。对端关闭或者管道破裂等情况。POLLNVAL:fd 未打开,或者不是一个合法的 fd。
有一个特别容易踩的坑:POLLERR、POLLHUP、POLLNVAL这几个事件,即使你没在events里请求,只要发生了,内核照样会在revents里给你标上。所以检查 revents 时不能只if (revents & POLLIN)就完事,还得处理异常情况,否则可能出现 fd 已经 HUP,你却永远不 close 它,造成连接泄漏。
TCP 对端关闭时,最常见的可靠处理方式是:如果返回了POLLIN,就去调用read,读到 0 就认为是关闭,然后清理这个 pollfd。不要把逻辑只押在POLLHUP上,不同系统、不同协议细节下行为会有差异。
4.3 poll 的超时粒度与 select 的另一个不同
poll 的timeout单位是毫秒,直接传 int:
-1:永久阻塞,直到有事件发生。0:立即返回,做一次非阻塞检查。>0:最多等待这么多毫秒。
相比 select 的timeval(秒+微秒),poll 的精度更粗糙一点,但对绝大多数网络服务来说毫秒级足够。如果你的定时器需要微秒级精度,那不该靠 poll 来睡,应该用专门的定时器机制。
4.4 poll 仍然存在的两个核心成本
poll 解决了 select 的“上限问题”,但它没有解决两个本质成本:
- 每次 poll 调用,仍然要把整个 pollfd 数组从用户态拷贝到内核态。不要以为
events不会被改就不传了,拷贝照样会发生。 - 内核返回后,你仍然要线性遍历数组,才能找出哪些 pollfd 的
revents非 0;内核自己也要在内部把数组过一遍,才能收集到所有事件。
所以 poll 的复杂度依然是 O(n),n 是监听 fd 的总数。fd 总数比较少时这个