9、poll I/O复用:使用poll()替代select(),突破文件描述符上限。

好,咱们继续往下聊。上一章我们把 select() 翻了个底朝天,它的优点和缺点,我相信你已经心里有数了。说白了,select() 最大的痛点是啥?就是那个 FD_SETSIZE 的限制,默认 1024。你想想看,现在一个高并发服务器,动辄几千上万个连接,1024 这个数,根本不够用。

那怎么办?换!用 poll()。我个人习惯是,只要项目里文件描述符可能超过 1024,我就直接上 poll(),省得后面还要重构。今天我们就来彻底搞懂 poll(),看看它是怎么突破这个上限的,以及它和 select() 到底差在哪。

9.1 poll() 的核心数据结构

poll()select() 最大的不同,在于它不再用位图(bitmap)来管理文件描述符了。它用的是一个结构体数组。这个结构体叫 struct pollfd,定义在 <poll.h> 里。

struct pollfd {
    int   fd;         /* 要监视的文件描述符 */
    short events;     /* 感兴趣的事件(输入) */
    short revents;    /* 实际发生的事件(输出) */
};

你看,每个 pollfd 结构体就管一个文件描述符。你关心多少个 fd,就准备多少个这样的结构体。没有上限!当然,理论上受内存限制,但实际项目中,几万个 fd 是轻轻松松的。

events 是你告诉内核:“我想看这个 fd 上发生什么事”。revents 是内核告诉你:“刚才这个 fd 上发生了这些事”。常用的宏有:

  • POLLIN:有数据可读(类似 select() 的读集)
  • POLLOUT:可以写数据(类似 select() 的写集)
  • POLLERR:发生错误
  • POLLHUP:对方关闭连接

嗯,这里要注意:POLLINPOLLOUT 是最常用的,POLLERRPOLLHUP 一般不需要你设置,内核会自动在 revents 里返回。

9.2 poll() 的函数原型

#include <poll.h>

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

三个参数,很简单:

  • fds:指向 pollfd 数组的指针
  • nfds:数组里有多少个元素(也就是你要监视多少个 fd)
  • timeout:超时时间,单位是毫秒。传 -1 表示永远等,传 0 表示立即返回(轮询模式)

返回值:成功时返回有事件发生的 fd 数量;超时返回 0;出错返回 -1。

你看,这个接口比 select() 清爽多了。没有那四个让人头疼的 fd_set 参数,也没有那个 maxfd 参数。你只需要准备好数组,告诉内核数组有多大,然后等着就行。

9.3 实战:用 poll() 实现一个简单的回显服务器

光说不练假把式。我们来写一个用 poll() 管理的 TCP 回显服务器。这个例子我当年在做一个嵌入式网关项目时用过类似的模式,稳定运行了好几个月没出过问题。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <poll.h>
#include <errno.h>

#define PORT 8888
#define MAX_CLIENTS 2000
#define BUFFER_SIZE 1024

int main() {
    int listen_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct pollfd fds[MAX_CLIENTS + 1];  // +1 给监听 socket
    int nfds = 1;  // 当前有效的 fd 数量
    int i, ret;
    char buffer[BUFFER_SIZE];

    // 1. 创建监听 socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 设置端口重用,避免 "Address already in use"
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 2. 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(PORT);

    if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind");
        close(listen_fd);
        exit(1);
    }

    // 3. 开始监听
    if (listen(listen_fd, 10) < 0) {
        perror("listen");
        close(listen_fd);
        exit(1);
    }

    printf("Echo server listening on port %d\n", PORT);

    // 4. 初始化 pollfd 数组
    memset(fds, -1, sizeof(fds));  // 把所有 fd 设为 -1
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;  // 只关心监听 socket 的可读事件(有新连接)
    nfds = 1;

    // 5. 主循环
    while (1) {
        ret = poll(fds, nfds, -1);  // 永远等待
        if (ret < 0) {
            perror("poll");
            break;
        }

        // 检查监听 socket 是否有新连接
        if (fds[0].revents & POLLIN) {
            conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
            if (conn_fd < 0) {
                perror("accept");
                continue;
            }

            printf("New connection from %s:%d, fd=%d\n",
                   inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), conn_fd);

            // 把新连接加入 pollfd 数组
            for (i = 1; i <= MAX_CLIENTS; i++) {
                if (fds[i].fd == -1) {
                    fds[i].fd = conn_fd;
                    fds[i].events = POLLIN;
                    if (i >= nfds) nfds = i + 1;
                    break;
                }
            }

            if (i > MAX_CLIENTS) {
                printf("Too many clients, closing new connection\n");
                close(conn_fd);
            }

            ret--;  // 消耗掉一个事件
        }

        // 检查所有客户端 socket
        for (i = 1; i < nfds && ret > 0; i++) {
            if (fds[i].fd == -1) continue;

            if (fds[i].revents & POLLIN) {
                // 有数据可读
                memset(buffer, 0, BUFFER_SIZE);
                int n = read(fds[i].fd, buffer, BUFFER_SIZE - 1);
                if (n <= 0) {
                    // 连接关闭或出错
                    if (n == 0) {
                        printf("Client fd=%d closed connection\n", fds[i].fd);
                    } else {
                        perror("read");
                    }
                    close(fds[i].fd);
                    fds[i].fd = -1;
                } else {
                    // 回显数据
                    printf("Received from fd=%d: %s", fds[i].fd, buffer);
                    write(fds[i].fd, buffer, n);
                }
                ret--;
            }

            // 检查是否有错误或挂起
            if (fds[i].revents & (POLLERR | POLLHUP)) {
                printf("Error or hangup on fd=%d\n", fds[i].fd);
                close(fds[i].fd);
                fds[i].fd = -1;
                ret--;
            }
        }
    }

    // 清理
    for (i = 0; i < nfds; i++) {
        if (fds[i].fd != -1) {
            close(fds[i].fd);
        }
    }

    return 0;
}

这段代码的核心逻辑,我画了个图帮你理解:

poll() 回显服务器事件处理流程 poll(fds, nfds, -1) 监听socket有事件? accept() 新连接 遍历客户端 pollfd 数组 read() / write() / close()

9.4 poll() 和 select() 的对比

我把它们俩的核心差异整理成了一张表,方便你对比:

对比项 select() poll()
数据结构 位图(fd_set),大小固定 结构体数组(pollfd),大小动态
文件描述符上限 FD_SETSIZE(通常 1024) 无硬性上限(受内存限制)
事件注册方式 每次调用前都要重新设置 fd_set 只需设置 events 字段,内核不修改它
事件返回方式 修改 fd_set,需要 FD_ISSET 检查 填充 revents 字段,直接判断即可
跨平台性 几乎所有平台都支持 POSIX 标准,Linux/Unix 支持良好
性能(大量 fd) O(n) 遍历,但位图操作有开销 O(n) 遍历,但结构体数组更高效

核心结论:poll() 是 select() 的升级版,解决了文件描述符上限问题,接口也更简洁。但两者都是「水平触发」(Level-Triggered),且都需要 O(n) 遍历所有 fd 来找出有事件的那个。如果你要处理上万连接,后面我们讲的 epoll 才是终极方案。

9.5 避坑指南:我踩过的 poll() 的坑

我曾经在一个物联网项目中,用 poll() 管理几百个设备连接。一开始跑得好好的,但运行几天后,发现新设备连不上来了。排查了半天,发现问题出在 nfds 的维护上。

坑1:nfds 不是数组大小,而是最大索引+1

poll() 的第二个参数 nfds 表示数组中有多少个有效的元素。如果你数组大小是 2000,但只用了 100 个,nfds 应该传 100,而不是 2000。传大了,内核会遍历很多无效的 fd == -1 的条目,浪费性能。传小了,后面的 fd 就监视不到了。

坑2:关闭 fd 后,一定要把对应的 pollfd.fd 设为 -1

如果不这样做,下次 poll() 返回时,你可能会拿到一个已经关闭的 fd 的事件。更糟糕的是,如果这个 fd 号被新连接复用了,就会产生混乱。我那次排查了半天,最后发现就是忘了把关闭的 fd 置为 -1。

小技巧:我习惯在初始化数组时,把所有 pollfd.fd 设为 -1。然后在添加新 fd 时,找到第一个 fd == -1 的位置插入。这样管理起来非常清晰,也不容易出错。

9.6 poll() 的 timeout 参数详解

timeout 参数有三种典型用法:

  • timeout = -1:永远等待,直到有事件发生才返回。适合服务器主循环。
  • timeout = 0:立即返回,不等待。适合轮询模式,比如你想检查一下有没有数据,没有就继续干别的事。
  • timeout > 0:等待指定的毫秒数。适合需要定期做其他任务的场景,比如每 100ms 检查一次,同时处理心跳包。

嗯,这里有个细节:timeout 的单位是毫秒,但实际精度取决于操作系统。Linux 上一般能精确到毫秒级,但如果你需要微秒级的精度,那得用 ppoll()(poll 的增强版,支持纳秒精度)。不过大多数场景下,毫秒级已经够用了。

9.7 什么时候该用 poll()?

我个人觉得,poll() 最适合的场景是:

  • 文件描述符数量在几百到几千之间
  • 对性能要求不是极端苛刻(比如不是百万并发)
  • 代码需要较好的可移植性(poll 是 POSIX 标准)
  • 你不想引入 epoll 的复杂性(epoll 虽然性能好,但 API 更复杂)

说白了,poll()select()epoll() 之间的一个很好的平衡点。它比 select() 灵活,比 epoll() 简单。很多中小型项目,用 poll() 就足够了。

好了,关于 poll() 的内容就讲到这里。你把它和上一章的 select() 对比着看,会发现网络编程的 I/O 复用模型其实是一脉相承的——从 selectpoll,再到后面的 epoll,每一步都是在解决前一步的痛点。理解了这些演进逻辑,你写起代码来就会更有底气。


公众号:蓝海资料掘金营,微信deep3321