上一篇我们把 select 从接口讲到服务器实战,又把它三根最硬的刺拔了出来:FD_SETSIZE 的 1024 上限、调用返回就"毁"掉你传进去的 fd_set 逼得你每轮重建、以及内核全量拷贝加线性扫描的 O(n) 开销。正因为如此,poll 应运而生——它几乎是同一个时代为解决 select 的毛病而给出的"改良款"。

这一篇,我们就顺着 select 的每个痛点,去看 poll 是怎么一一回应的。你会看到它的核心思路变了:不再用"一张位图 + 每轮重建",改用"一个结构体数组,把 fd 和它关心的事件绑在一起,交给内核并且不被就地改毁"。读完这一篇,你不仅能看懂那段"加餐"里的 poll 服务器改造,还能自己写出一版完整的 poll 回显服务器。

你应该有的知识准备

这一篇紧接 select 那一篇。如果你还没看过《多路转接 select》,强烈建议先回去补上,因为全篇都在和 select 做对比。你需要先熟悉这些东西:

  • fd_set、FD_ZERO/FD_SET/FD_ISSET:select 用来表达"监控名单"的位图及其操作宏。
  • nfds = max_fd + 1、timeout 三种取值、返回三态:select 的接口约定,poll 会重新解释其中一部分。
  • TCP 服务器五件套:socket、bind、listen、accept、read/write,以及上篇那个 select 版回显服务器的完整写法。

如果你把 select 篇的 select_server.c 亲手敲过、跑过,那太好了——这一篇几乎就是"把那份代码翻译成 poll 的语法",你会觉得处处似曾相识。

先回到 select 的三根刺,看 poll 对应要拔哪根

在动手写代码之前,我们先把"poll 到底想解决什么"钉清楚。用一张对照表把 select 的三个毛病和 poll 的回应放在一起,后面每一节就是在填这张表的每一个格子:

select 的毛病poll 的回应
fd_set 是编译期定死的位图,最多 FD_SETSIZE(默认 1024)个位来放 fd用一个你自己开的、大小随意的 struct pollfd 数组,数组有几个元素就最多监控几个 fd,不依赖编译期常量
select 返回会就地改写 readfds(只留就绪的位),每轮必须从备份数组全量重建poll 返回时只写 revents,绝不触碰你填的 events,所以不需要每轮重建监控清单
三套独立的集合(readfds/writefds/exceptfds),只能按"读/写/异常"整体归类每个 pollfd 把 fd 和它专属的 events 绑在一起,可以对每个连接精确控制它关心什么事件

一句话先立起来,贯穿全篇:select 的模型是"位图 + 返回值毁灭式修改入参",poll 的模型是"结构体数组 + 只回填结果、不清空你的设置"。 这就是"加餐"那份课件里反复强调的"poll 不会就地修改"的真正含义。

poll 的核心:struct pollfd 结构体

poll 的一切都围绕一个结构体展开。它定义在头文件 <poll.h> 里,长这样:

#include <poll.h>
 
struct pollfd {
    int   fd;        // 文件描述符
    short events;    // 期望监控的事件(调用者填写,告诉内核"我关心什么")
    short revents;   // 实际发生的事件(内核返回时回填,告诉我们"哪些就绪了")
};

只有三个字段,但信息量极大,因为它把 select 里"三张独立位图"要表达的信息,压缩到了一个元素里:fd 负责"监控哪一路",events 负责"这一路我关心什么事件"。至于返回之后"到底发生了什么",写在 revents 里。

关于 pollfd,有两条你必须一开始就刻进脑子的约定:

  • fd == -1 表示这个空槽不被参与。poll 在扫描数组时遇到 fd == -1 的条目会直接跳过,既不监控它,也不会写它的 revents。这一条在"服务器满了想腾位置""客户端断开了想摘掉它"时,是我们做删除/置空的标准手势。
  • fd 是 int;events、revents 是 short(16 位短整型)。也就是说,一个 pollfd 的元素能表达的"事件位"受 short 的 16 位限制。好在 Linux 的 POLL* 这些事件常量值都在低 16 位之内(POLLIN=0x001、POLLOUT=0x004、POLLHUP=0x010……),所以实际用起来短整型不会成为掣肘,但你写库、做 ABI 对接时要知道这两个字段确实是 short。

再强调一次"不会就地修改"这个关键点——它落到 pollfd 上就是:poll 只会往 revents 里写东西,绝不改动 events。 你上一轮告诉内核"fd 7 关心 POLLIN",这一轮 poll 的入参 events 还是 POLLIN,不可能被清掉。这正是和 select 天差地别的地方。

events 与 revents:一对分工明确的字段

很多同学第一次看到 pollfd 会懵:为啥要俩一模一样的 short?这里把它们的角色彻底讲清晰,你就永远不会混:

  • events 是"期望值",由你(调用者)填,内核只读不写。 它回答的问题是:"这一路 fd,我希望你 poll 帮我盯着什么事件?"就好像你给保安下了张巡逻单:"帮我盯着 3 号窗口有没有人排队(POLLIN),5 号窗口是不是空闲(POLLOUT)。"
  • revents 是"实际值",由内核写,你只读。 它回答的问题是:"这一轮 poll 醒来,哪些事件确实发生了?"它可能包含你在 events 里请求的事件,也可能包含某些你根本没想到要请求、但偏偏发生了的"意外事件"。

把它们放在一起看就是一个完整的回合:你先设 events → 调用 poll → 内核睡一觉 → 醒来写 revents → 你读 revents 决定下一步。revents 里为 0 的位,就意味着"这件事件这一轮没发生"。

这里藏着一个新手最容易懵的点,我提前给你打个预防针:revents 里出现的位,并不一定是你在 events 里请求过的。 特别是 POLLERR、POLLHUP、POLLNVAL 这三个"坏消息",不管你 events 有没有写,只要发生了,内核就一定会塞进 revents。为什么要这样设计?因为"连接出错了、连接被挂了"这种异常你必须第一时间知道,值不值得关心是内核替你做主、不容商量的——举个例子,对端 close 之后,你哪怕只写了 events = POLLIN,poll 也会在 revents 里同时把 POLLHUP(甚至 POLLIN | POLLHUP)一起给你。这一点稍后写服务器时极其重要。

注意一个必须养成的素养:revents 的判断永远是"用位与 &",而不是"等于 ==。 因为 revents 常常是多个事件同时置位(比如 POLLIN | POLLHUP、POLLOUT | POLLERR),你写 revents == POLLIN 几乎必然漏掉情况。正确的姿势是 if (revents & POLLIN)——"POLLIN 这一位是不是置 1 了"。这个习惯从 poll 起就是铁律,到了 epoll 一样适用。

poll 的事件标志:POLLIN 与它的伙伴们

events 和 revents 里的"位"都是有名字的宏。我把它们按"你能不能在 events 里请求它"分两类,这是理解和使用的分水岭:

第一类:能写进 events(可以主动请求的事件)

标志含义说明
POLLIN可读有普通数据可读、或对端关闭了连接(此时 read 返回 0)、或监听 socket 上有新连接等待 accept
POLLOUT可写发送缓冲有空间,write 不会阻塞
POLLPRI紧急/带外数据socket 有紧急数据(对应 select 的 exceptfds)
POLLRDHUP对端关闭了写半连接(Linux 扩展)需要定义 _GNU_SOURCE 才可用,属于更精细的"检测对端 shutdown(SHUT_WR)"手段

POLLIN 是我们这篇最重要的主角——绝大多数服务器阶段只关心"可读",所以 events 几乎总写 POLLIN。注意它的含义非常丰富:"可读"不等于"有业务数据"。它包含了三种情况:(1) 普通 TCP 客户端 socket 上有字节可读;(2) 监听 socket 上有已完成的连接(对应 select 里"监听 socket 可读 = 可以 accept");(3) 对端关闭导致 read 返回 0(EOF)。所以处理"读就绪"时,你依然要像 select 篇那样,想清楚 read 返回三种值(大于 0、等于 0、小于 0)分别怎么办。

第二类:只出现在 revents、永远不要写进 events

标志含义说明
POLLERR出错socket 上有错误条件,需要读 SO_ERROR 或直接收尾
POLLHUP挂起对端关闭了连接(本端 socket 收到 FIN/RST)
POLLNVAL非法请求这个 fd 根本没有被打开,或已经 close 了

这三个永远不要写进 events 去"请求",你写了内核也不会因此就有别的行为——它们的出现不归 events 管。它们的意义全在 revents:一旦出现,说明这条路基本报废,你的处理动作一般是"关闭这个 fd,把它从监控数组里摘掉"。

这里立刻牵扯出一个关键点:如何判断"客户端断开了"? 传统 select 服务器靠"read 返回 0 或 -1"来感知断开。poll 多给了你一个提前量:对端 close 之后,poll 会把 POLLHUP 塞进 revents。但大多数情况下对端关闭会让 revents 同时出现 POLLIN | POLLHUP(因为 FIN 也算"可读"的一种信号),所以你既要在发现 POLLHUP 时善后,也仍要在 POLLIN 就绪后去 read,直到 read 返回 0 才算彻底确认"对端把数据读完了、连接结束了"。真实服务器里两手都要抓——这正是下面服务器代码要演示的。

poll 的函数原型与三个参数

认识了 pollfd,就看 poll 本身。它的原型在 <poll.h> 里:

#include <poll.h>
 
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

只有三个参数,比 select 的五个清爽得多。逐个拆:

参数一:fds——整个 pollfd 数组

fds 指向一段连续的 struct pollfd 数组内存。你在这个数组里写下"监控哪些 fd、各自关心什么事件",poll 就按这份清单去监视。和 select 的"三张独立集合指针"相比,它的表达能力更内聚——每一条都是独立的 {fd, events, revents},你可以给 fd 3 只关心 POLLIN,给 fd 5 只关心 POLLOUT,互不干扰。

参数二:nfds——数组的长度,不是 max_fd+1

这是 poll 与 select 在"nfds"上最根本的区别,一定要分清楚:

  • select 的 nfds 是"最大的 fd 编号 + 1",是一个数值区间,受 FD_SETSIZE 限制;
  • poll 的 nfds 是"fds 数组里有多少个元素",是一个数组长度,填多少它扫多少。

nfds_t 是一个"无符号整数类型"(Linux 下通常是 unsigned long)。正因为 poll 的监控范围由"数组多大"决定、而不是编译期定死的位图位数,它才天然突破了 select 的 1024 上限——这是 poll 相比 select 最有价值的地方,我们压到后面"关键改进"一节再去展开。

参数三:timeout——最多等多久(单位是毫秒)

poll 的 timeout 是 int,单位是毫秒(1 秒 = 1000 毫秒)。它有三种取值,和 select 的三种含义遥相呼应:

timeout 取值含义
-1(负值)永久阻塞,直到有 fd 就绪才返回(等价 select 的 timeout == NULL)
0立即返回,只"看一眼"当前有哪些就绪(等价 select 的 {0,0},常用来做非阻塞轮询)
> 0最多等这么多毫秒,超时未就绪则返回 0

一个和 select 的细腻差别值得记住:select 会在返回时把 timeout(timeval)就地改成剩余时间;而 poll 的 timeout 是普通 int 形参、按值传递,poll 绝不会修改它。 所以你完全可以在循环外定义一个 timeout = 500 的变量,每轮 poll 安全地复用同一个值,不用担心它被"偷改"。这是"poll 不会就地修改参数"的又一个体现。

poll 的返回值三态

选完参数,看 poll 回报什么。它的返回值和 select 语义完全一致,也是三态:

返回值含义
> 0就绪的 fd 个数。注意这是"有几个 fd 发生事件了",但不直接告诉你是哪几个——你仍然要遍历整个 fds 数组,逐个看 revents
0在 timeout 时限内没有任何 fd 就绪(超时返回)
-1出错,错误码在 errno 里

出错时最常碰到的是 EINTR(被信号打断,比如收到 SIGCHLD、SIGINT)。处理方式和 select 一样:while (poll(fds, nfds, -1) < 0 && errno == EINTR) ; 的语义——被信号打断不是世界末日,继续重试即可。另外 EBADF 在某些细节上与 select 略有不同(见"坑点合集")。

特别注意返回值只给"数量"、不给"名单":poll 返回 3,意味着"有 3 个 fd 就绪了",但你不知道哪 3 个。你依然要写一个 for 循环从 fds[0] 扫到 fds[nfds-1],用 revents & POLLIN 逐个判定。这个"返回就绪数、但还得线性扫一遍找具体是谁"的特性,就是 poll "进化了一截、但没完全进化"的地方——效率上它比 select 好的有限,后面我们会专门展开。

poll 比 select 强在哪:三个关键改进

前面反复埋的几个点,现在郑重地收网。poll 相对 select 的改进,可以归纳为三件实事。

改进一:不会就地修改 events,每条监控记录长期有效

这是 poll 最核心的体验提升。select 返回会把整张 fd_set 改写成"只有就绪的位",所以你必须拿一张"幕后花名册"每轮重新 FD_ZERO + FD_SET。poll 呢?它只写 revents,events 原封不动。 于是:

  • 你不需要每轮重新填充数组——只要在新连接到来、旧连接断开时维护这个数组即可,这是"增量式"的维护;
  • fds 数组本身既是"监控清单",又是"就绪判定依据",不再需要像 select 那样另备一份"原始数据"。

回想 select 篇那句"必须备一份花名册、每轮重新搭一遍"的心智模型,在 poll 里被彻底拆除——数组本身就是那份永不过期的花名册。这是从"位图 + 每轮重建"到"结构体数组 + 长期有效"的根本转变。

改进二:突破 1024 上限,监控数量由数组长度决定

select 能监控多少 fd,由编译期定死的 FD_SETSIZE(默认 1024)决定;poll 则把决定权交还给你——fds 数组你开多大、nfds 传多大,就能监控多少个 fd。 想要监控 5000 路?struct pollfd fds[5000]; 然后 poll(fds, 5000, -1),完全合法。

当然要泼一盆冷水保持清醒:poll 的上限不再受编译期宏限制,却依然受"你到底能向系统要到多少个 fd"的限制——也就是常说的 ulimit -n(单进程可打开的文件描述符上限,Linux 默认 1024,可用 ulimit -n 查看、ulimit -n 65535 临时调大)。这是操作系统层面的硬约束,和 select 的 1024 不是一回事,但客观上 poll 在默认配置下能从 select 手里多榨出来一点点空间,并且这个空间是可配置可扩的。

# 查看当前 shell 里单进程最多能打开多少文件描述符(含 socket)
ulimit -n
# 想临时调到 65535(可选,root 或设置了相应 limit 才允许)
ulimit -n 65535

改进三:按需监控,每个 fd 的事件精确到"这一路"

select 把世界切成三张集合——读的、写的、异常的,你只能"把一个 fd 放进某类"。poll 则允许对每一个 fd 单独指定它的 events:fd 3 关心可读,fd 5 关心可写,fd 7 同时关心可读和可写,全写进各自的 pollfd.events 里。这个粒度对服务器业务非常有价值——比如那些"既想读数据、又想知道写缓冲区是否有空间再发大响应"的连接,在 poll 里就是设置 events = POLLIN | POLLOUT 这么简单。

把三件改进合起来看,poll 其实是在用"更内聚、更持久、更可控"的结构,正面解决 select 的三个毛病。但请注意:体积上它解决了,复杂度上它没有。 下一节我们会看到,poll 依然逃不了线性扫描。

实例:用 poll 写一个多客户端回显服务器

现在进入本篇的重头戏——把 select 版回显服务器"改写"成 poll 版。对比着看会特别有感觉:整体骨架几乎一样,变的只是"怎么维护监控清单"和"怎么判断就绪"。

先对齐业务目标(和 select 篇一模一样):客户端发来什么,服务器原样回什么。要监视的对象分两类:

  • 监听 socket 可读 → 有客户端连入 → accept 拿新 fd → 把新 fd 放进 poll 数组一个空槽;
  • 普通客户端 socket 可读 → read 收数据 → 原样 write 回显;若读到 0(断开)或出错 → close 并把该槽位清空(fd = -1)。

下面是完整可编译的 C 实现,每一行都写了注释:

// poll_server.c —— 用 poll 实现的多客户端"回显服务器"
// 原理:用一个 struct pollfd 数组保存所有要监视的 fd 及其关心的 events,
//       每轮把整个数组交给 poll,返回后再逐个看 revents 谁就绪了。
// 相比 select 版最大变化:不再有"每轮重建 fd_set",监控清单就是数组本身。
// 编译:gcc poll_server.c -o server
// 运行:./server
// 测试:另开终端执行  nc 127.0.0.1 8888 ,连上后发几行字看回显;可开多个 nc 并行
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <poll.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
 
#define PORT    8888      // 服务器监听的端口
#define MAX_FD  2048      // 最多能同时监控的 fd 数(演示可超过 select 的 1024)
 
int main(void)
{
    // ---- 1. 创建监听套接字 ----
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); exit(1); }
 
    // 允许端口复用:快速重启服务器时,避免上一条连接仍占用端口导致 bind 失败
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
 
    // ---- 2. 绑定地址和端口 ----
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));                  // 清零,避免残留垃圾
    addr.sin_family = AF_INET;                       // IPv4
    addr.sin_port   = htons(PORT);                   // 端口(网络字节序)
    addr.sin_addr.s_addr = htonl(INADDR_ANY);        // 绑定本机任意地址
    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); exit(1);
    }
    if (listen(listen_fd, 8) < 0) { perror("listen"); exit(1); }
    printf("server started, port %d\n", PORT);
    fflush(stdout);
 
    // ---- 3. 准备 pollfd 数组(监控清单)----
    // 与 select 关键不同:监控清单就是这个结构体数组本身,长期有效,不需要每轮重建
    struct pollfd fds[MAX_FD];      // 一整块 pollfd 数组
    int nfds = 1;                   // 数组当前"用到的长度"(我们用它当 poll 的参数)
 
    // 先把所有槽位初始化成"空":fd = -1 表示这个槽位不参与监控
    for (int i = 0; i < MAX_FD; ++i) {
        fds[i].fd = -1;
        fds[i].events = 0;
        fds[i].revents = 0;
    }
 
    // 第 0 号槽放监听 socket,关心"可读"(即:有没有客户端连进来)
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;
 
    // ---- 4. 事件循环 ----
    for (;;) {
        // 4.1 阻塞等待(timeout = -1 表示永不超时)。
        //     注意:不需要像 select 那样每轮重建监控清单——poll 不会动 events
        int ret;
        // 处理 EINTR:poll 是慢系统调用,可能被信号打断,遇到就重试一次
        do {
            ret = poll(fds, nfds, -1);
        } while (ret < 0 && errno == EINTR);
 
        if (ret < 0) { perror("poll"); continue; }  // 其它错误,跳过本轮
        if (ret == 0) { continue; }                 // 超时返回(此处 -1 永不超时,防御性写上)
 
        // 4.2 poll 只告诉你"有几个就绪",具体是哪几个还得遍历数组看 revents
        //     这就是 poll 仍要"线性扫描"的体现:哪怕只就绪 1 个,也要扫一轮数组
        for (int i = 0; i < nfds; ++i) {
            if (fds[i].fd == -1) continue;          // 空槽位,跳过
            int fd = fds[i].fd;
            short rev = fds[i].revents;             // 取出这一轮回填的就绪事件
 
            // 坏消息优先处理:出错 / 挂起 / 非法 fd,这条路基本报废,直接收尾
            // 注意这三个标志不需要在 events 里请求,poll 也会回填到 revents
            if (rev & (POLLERR | POLLHUP | POLLNVAL)) {
                printf("fd %d closed by poll (hup/err/nval)\n", fd);
                close(fd);                          // 关闭连接
                fds[i].fd = -1;                     // 槽位置空:以后 poll 会忽略它
                fds[i].events = 0;
                fds[i].revents = 0;
                continue;
            }
 
            // 这个 fd 这一轮没有"可读"事件,直接看下一个
            if (!(rev & POLLIN)) continue;
 
            if (fd == listen_fd) {
                // ---- 4.2.1 监听 socket 可读:有客户端连入,accept ----
                struct sockaddr_in peer;
                socklen_t len = sizeof(peer);
                int new_fd = accept(listen_fd, (struct sockaddr*)&peer, &len);
                if (new_fd < 0) { perror("accept"); continue; }
 
                // 找一个空槽来放新 fd(返回就绪数不够时,poll 会忽略 fd=-1 的槽)
                int j = 0;
                while (j < MAX_FD && fds[j].fd != -1) ++j;
                if (j == MAX_FD) {                  // 数组都装满了 = 连接过多
                    printf("too many connections, drop new\n");
                    close(new_fd);                  // 拒绝并关闭这个多余连接
                    continue;
                }
                fds[j].fd = new_fd;                 // 登记新连接
                fds[j].events = POLLIN;
                fds[j].revents = 0;
                if (j + 1 > nfds) nfds = j + 1;     // 扩展"用到"的长度
                printf("new client on fd %d\n", new_fd);
                fflush(stdout);
            } else {
                // ---- 4.2.2 普通客户端可读:回显 ----
                char buf[1024];
                ssize_t r = read(fd, buf, sizeof(buf));
                if (r <= 0) {
                    // r == 0:对端关闭;r < 0:出错。两种情况都该关闭并摘出监控
                    printf("client fd %d quit\n", fd);
                    close(fd);
                    fds[i].fd = -1;
                    fds[i].events = 0;
                    fds[i].revents = 0;
                    continue;                       // 本槽已空,直接看下一个
                }
                write(fd, buf, (size_t)r);          // 原样回显客户端发来的内容
            }
        }
    }
    return 0;
}

试着编译运行验证:

# 编译
gcc poll_server.c -o server
 
# 终端A:启动服务器
./server
 
# 终端B/C/D:开多个终端同时连接并发消息
nc 127.0.0.1 8888

你会观察到和 select 版一模一样的"多路同时伺候"效果:多个 nc 连接同时发消息互不阻塞,服务端滚动打印每个 fd 的连入和断开。区别藏在代码的骨架里:我们彻底删掉了 FD_ZERO、FD_SET、FD_ISSET 和那份"每轮重建的备份数组",改成了维护一个 struct pollfd 数组。 这就是"从 select 到 poll 的代码改写"最核心的一步翻译。

和 select 版逐点对照,看"改写"到底改了哪几处

请把这段代码和上一篇 select_server.c 放在一起,你会发现它是"三处替换、一处增强、一处新增":

  1. 监控清单的数据结构,从"fd_set 位图"换成了 struct pollfd 数组。 select 版里 fds[] 只装 fd 编号 + 一个 fd_cnt;poll 版直接换成 struct pollfd fds[MAX_FD],脏活全在图省事的结构体里。

  2. "每轮重建集合 + 算 nfds"这个动作,直接消失了。 select 每轮要 FD_ZERO → 循环 FD_SET → 循环求 max_fd;poll 全程只用改一次数组(新连接填槽、断开置空),nfds 只在数组变长时累加一次。这是从"每轮全量重来"到"仅增量维护"的飞跃。

  3. "就绪判定"从 FD_ISSET(fd, &read_fds) 换成了 revents & POLLIN。 select 靠位图查"这个 fd 在不在就绪位里";poll 读 revents 的位。"读数据、回显、断开收尾"的业务逻辑部分,两者几乎逐行一致。

  4. 多了一处"坏消息优先处理"。 select 版靠"read 返回 0 或 -1"兜底断开检测;poll 因为 POLLERR/POLLHUP/POLLNVAL 会上报,我们干脆在最前面拦截,一看到就立刻收尾。这属于 poll 给我们的"提前感知断开"的额外福利。

  5. 删除了 MAX_FD 和保护逻辑中的 FD_SETSIZE。 select 版的"花名册满了"用 fd_cnt >= FD_SETSIZE 判断;poll 版用 j == MAX_FD 判断,且 MAX_FD 是我们自己定的 2048——上不封顶(封顶的是系统 ulimit)。

poll 仍然存在的不足:它进化了,但没进化完

poll 把 select 那些接口上的毛病修得很漂亮,但它远不是终点。"更上一层楼"是 epoll 的事,poll 自己还留着几个硬伤。诚实地讲清楚,你才不会对 poll 抱有不切实际的期待。

不足一:仍需线性扫描整个数组

poll 返回的只是"就绪的 fd 个数",不告诉你具体是哪几个。于是你每轮都要写一个 for 循环遍历 fds[0..nfds-1],逐个看 revents 才知道谁就绪了。即便你监控 1000 路、只有 1 路就绪,poll 醒来后你也得把这 1000 个 pollfd 从头到尾扫一遍,才找到那 1 个。这正是 select 篇"缺点三"的 O(n) 扫描在 poll 里的残留——poll 只是把"扫描 fd_set 找位"换成了"扫描数组找 revents",量级没变,仍是 O(n)。

不足二:整块数组每轮全量拷进内核

每次调用 poll,都要把整个 pollfd 数组(nfds × sizeof(struct pollfd),struct pollfd 有 3 个字段 8 字节)从用户态拷贝到内核态。连接数上万时,这个拷贝是大开销。这不只 poll 有——把"监控清单塞给内核"这件事,任何一次调用都不能免,区别只在于是"全量重传"还是"增量告知"。poll 属于全量重传的阵营。

不足三:内核里也要线性扫描 fds 数组

内核拿到 fds 数组后,要逐条去看每个 fd 的就绪状态、设置对应的 revents。这同样是 O(n)——把"扫描"这活儿从用户态挪到了内核态,但扫描本身没减少。聚合起来,poll 的每轮成本依旧至少是 O(n) 的"喝咖啡 + 扫描",高并发下依旧吃力。

不足四:旧连接的"空洞"会一直占着扫描量

poll 里我们处理断开是把槽位置成 fd = -1。这么做监控是安全的(内核会忽略空槽),但空槽并没有从数组里"消失"——下一轮 poll 依然会扫过这段区间。真实的高性能方案(epoll)会用"事件表 + 主动删除",彻底告别"数组里的洞"。

把这些串成一句话:poll 把 select 的"复杂度缺陷"(每轮重建、接口别扭)修好了,却没修"性能缺陷"(线性扫描、全量拷贝)。 理解了这个结论,你回头再看"加餐"课件里那句"poll 仍需在用户态遍历 fds 数组",就能会心一笑了。

边界情况与坑点合集

poll 看似清爽,实战里依旧处处是雷。一次给你排掉:

  1. events 里千万别写 POLLERR/POLLHUP/POLLNVAL。 它们是"结果",不是"请求"。你要做的不是在 events 请求它们,而是在 revents 里主动 & 它们,尽早发现异常。写进 events 虽然不会让程序崩溃,但没有意义,还容易误导自己以为"我主动管了"。

  2. revents 判断必须用位与 &,不要用 ==。 对端关闭时 revents 常是 POLLIN | POLLHUP。revents == POLLIN 一票否决,必然漏掉真实就绪的 fd;revents & POLLIN 才是"这一位在不在置 1"。这个习惯从 select 的 FD_ISSET 一路沿用到 epoll,是网络编程的基本盘。

  3. "可读"不等于"有数据",read 仍可能返回 0 或 -1。 对端关闭也算"读就绪",你 read 会拿到 0(EOF);对端发 RST 后你来读可能拿到 -1。处理"读就绪"的代码,必须同时处理"read 返回 0 / -1",通常是关闭并摘出。千万别以为 POLLIN 就一定能读出正数。

  4. 断开不一定非等 read 返回 0,POLLHUP/POLLERR 能让你提前知道。 但要注意:当对端"先发完数据、再 close"时,第一轮 poll 可能只给你 POLLIN(拿到数据),第二轮才把 POLLIN|POLLHUP 一起给你(此时 read 到 0)。所以逻辑上"数据读干净"和"连接关闭"是两件事,都要能兜住——我们的代码就同时处理了两条路径。

  5. EINTR 照样要重试。 poll 也是慢系统调用,会被信号打断返回 -1。前面代码里那个 do { poll(...) } while (ret < 0 && errno == EINTR); 就是在补这个洞。丢了这句,半夜被 SIGCHLD 之类随便打断一下,服务器就可能误判成"poll 永久性失败"。

  6. fd 编号不连续、nfds 只增不减,扫到"空槽"别慌。 accept 拿到的 fd 编号随机、断开后槽位可能留在中间。我们依靠"fd == -1 就跳过"来正确处理,而不是对"数组里必然紧密连续"抱任何幻想。

  7. 改课时最容易踩的笔误:sizeof(buffer - 1)。 课件的 C++ 版 PollServer 里出现过一句 recv(fd, buffer, sizeof(buffer - 1), 0),写成这样是完全错误的——buffer 在表达式中会"衰变"成指针,buffer - 1 就是指针减 1,对它求 sizeof 拿到的是指针的大小(64 位下是 8),而不是"数组减 1"。一次最多只读 8 字节,数据会被截断。正确写法是 recv(fd, buffer, sizeof(buffer) - 1, 0)——先 sizeof(buffer) 拿数组大小,再减 1 留一个位置放 '\0'。写服务器改代码的时候,这种 sizeof 配数组的括号位置一定要盯紧。

  8. select 篇关于 SIGPIPE 的教训照搬:往一个没在读、或已关闭写方向的 socket 写数据,也可能触发 SIGPIPE。 poll 篇主流程里我们读即回显,风险不大;但一旦你想"向可能已关闭写方向的连接写数据",记得提前 signal(SIGPIPE, SIG_IGN) 并检查 write/recv 返回值,否则进程会静默被杀。

思考题(附详解)

这几道题值在覆盖 poll 最容易被绕晕、也是最常被面试考到的点。请先自己动手推一遍,再对照详解。

思考题 1:为什么 poll 不需要像 select 那样"每轮重建监控集合"?它凭什么做到这一点的?

详解:因为二者的"就绪信息写在哪里"根本不同。select 把就绪信息就地写回原集合——返回时把你的 fd_set 改写成"只剩就绪的位",把没就绪的全清掉了,所以你必须拿备份数组每轮重新 FD_ZERO + FD_SET 来恢复。而 poll 把结果写在独立的 revents 字段里,你填的 events 字段内核只读不写、原封不动。events 里保存的是你对每个 fd"关注啥"的长久意愿,既然它每轮都还在,自然就不必重填。这也解释了 poll 的"监控清单增量维护"特性:你只在"新连接到来(填一个新槽)、旧连接断开(置空一个槽)"时动手改数组,飞行途中完全不用碰它。

思考题 2:poll 的 nfds 和 select 的 nfds 含义有何区别?为什么说 poll 能突破 select 的 1024 上限?

详解:select 的 nfds 是"最大 fd 编号 + 1",它是一个数值区间,用来告诉内核"从 0 扫到 nfds-1";它受 FD_SETSIZE(默认 1024)限制,这是编译期用位图大小定死的。poll 的 nfds 是"fds 数组的长度",填多长内核就扫多长,而数组多大完全由你说了算。所以 poll 的监控数量不再有"1024"这个魔咒,只要你把数组开够大(如 2048、65536),就可以监控多于 1024 个 fd。但注意,真正封顶的是 ulimit -n(单进程能打开的最大 fd 数,默认往往也是 1024),poll 只是把这个上限从"代码里定死的一位图"挪到了"可配置的系统参数",让高并发变得有可能,而不是凭空变出无限连接。

思考题 3:我在 events 里只写了 POLLIN,为什么 revents 里却可能出现 POLLHUP、POLLERR?这是 bug 吗?

详解:这不是 bug,而是 poll 的设计。POLLIN/POLLOUT/POLLPRI 这类属于"可请求事件",你主动声明才监控;而 POLLERR/POLLHUP/POLLNVAL 属于"特殊返回事件",只要发生,不管你是否请求,poll 一律回放进 revents。设计意图很直白:连接出错、对端挂断、fd 失效这类"坏消息",必须第一时间让你知道,值不值得关心由内核替你兜底,你不能糊里糊涂地错过。所以实战里你常见"只写了 POLLIN",revents 却给出 POLLIN | POLLHUP——这正是"提前感知对方关闭"的信号。相应地,你不要去 events 里写这三种,写不写结果一样,反而误导自己。

思考题 4:判断"客户端是否断开",poll 版最常见的可靠做法是什么?为什么"光看 revents 有没有 POLLHUP"还不够?

详解:最可靠的是"两手抓"。一方面在 revents & (POLLHUP | POLLERR) 时尽早收尾(提前感知);更重要的一方面,只要 revents & POLLIN,就去 read,并正确处理返回值:read == 0 表示读到 EOF、对端已优雅关闭;read < 0 表示出错。为什么光看 POLLHUP 不够?因为对端可能是"先发完最后一批数据、再 close"——它的数据还在内核缓冲区里,第一轮 poll 通常只给你 POLLIN(有数据可读),POLLHUP 要等你把缓冲区里现存的数据读完、下次再读时才会由 read 返回 0 来揭示。也就是说,"数据读干净"与"连接已关闭"是异步到达的两件事,靠读完返回 0 才是最终裁决,POLLHUP 只是提前的告警。我们服务器的代码就把这两条路径都处理了,正是这个道理。

思考题 5:poll 相比 select 解决了那么多痛处,为什么在高并发场景下它仍然不够,还需要 epoll?请至少说出两个 poll 在极端规模下的软肋。

详解:至少两条硬伤:(1) 返回就绪数但不知是谁——poll 醒来后必须线性扫一遍整个 fds 数组、逐个查 revents,才知道就绪的具体是哪些,复杂度 O(总 fd 数);(2) 每轮整块数组全量拷进内核——nfds × sizeof(struct pollfd) 的拷贝逃不掉,fd 越多拷贝越贵,且内核拿到后同样要线性扫一遍去设置 revents,也是 O(总 fd 数)。两项相加,poll 在"上万连接"下依旧是"全量搬运 + 全量扫描"的性能瓶颈。epoll 的划时代之处,就是它把"监控清单"长期挂在内核里的"就绪事件表"上,增删是增量的,返回时直接 O(就绪数) 汇报,彻底告别每次调用都全量搬运和全量扫描。这也是为什么真正的高并发服务器清一色选 epoll,而 poll/select 只在"连接数量不大、追求简单直白"的场景里依然有用武之地。

结语

这一篇我们从 select 的三根刺出发,看到 poll 是怎么一根根拔掉的:把"位图 + 每轮重建"换成"结构体数组 + 长期有效",用 events/revents 的分工实现了"不会就地修改",把危害 1024 的魔咒从 FD_SETSIZE 换到了数组长度,还给了每个 fd 按需监控的精细度。我们也亲手把 select 版回显服务器"改写"成了 poll 版,体会了那个最核心的翻译——"监控清单不再是每轮重建的位图,而是一份长期有效的 pollfd 数组"。最后我们也没回避它的缺失:poll 仍要线性扫描、仍要全量拷贝、内核仍然得逐条查——它修好了 select 的"接口别扭",却没修好"性能瓶颈"。

把这两篇连起来看,多路转接这条路的演进逻辑就非常清晰了:select 引入"位图 + 返回即毁入参"的笨办法,poll 把它换成"结构体数组 + 只回填结果",但它们都还困在"每次全量交给内核 + 醒来全量扫描"的牢笼里。 至于那座真正跳出牢笼的桥——把监控清单长期驻留内核、以就绪事件表驱动的 epoll——就是我们下一篇文章的主角。理解了 poll,你就拿到了通往 epoll 的完美跳板。准备好了吗?

参考资料

  • man 2 poll:poll 的手册页,pollfd、events/revents、POLL* 常量与返回值语义的权威出处。
  • man 2 select:select 手册页,用于对照两个接口的差异。
  • Linux 手册页在线版(Linux man-pages),poll/select 条目:https://man7.org/linux/man-pages/man2/poll.2.html