4. IO多路复用(中):poll系统调用、poll与select对比、poll实战案例

上一章我们把 select 翻了个底朝天。说实话,select 的问题我太熟悉了——每次上线前都要检查 FD_SETSIZE 够不够,1024 这个数字就像个紧箍咒。今天聊的 poll,就是来解决这些痛点的。

poll 的出现比 select 晚,但设计上明显更成熟。它没有最大文件描述符限制,接口也更清晰。我当年第一次用 poll 替换 select 时,感觉就像从手动挡换成了自动挡——虽然核心逻辑没变,但开起来舒服多了。

4.1 poll 系统调用详解

先看 poll 的函数原型:

#include <poll.h>

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

struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 监控的事件 */
    short revents;    /* 返回的事件 */
};

参数不多,但每个都很关键:

  • fds:pollfd 结构体数组,你要监控的所有 fd 都放这里
  • nfds:数组的大小,注意不是最大 fd+1
  • timeout:超时时间,单位毫秒。 -1 表示阻塞等待,0 表示立即返回

events 和 revents 是位掩码,常用的事件宏有:

事件宏 含义 可用于 events 可用于 revents
POLLIN 有数据可读 ✓ ✓
POLLOUT 可写数据 ✓ ✓
POLLERR 发生错误 ✗ ✓
POLLHUP 连接挂断 ✗ ✓
POLLNVAL fd 未打开 ✗ ✓
我的习惯:每次调用 poll 前,我都会把 revents 清零。虽然内核会帮你写,但手动清零能避免一些诡异的 bug。我曾经在一个长连接项目里吃过这个亏——某个 fd 的 revents 残留了上次的 POLLHUP,导致连接被误判为断开。

4.2 poll 的工作流程

poll 的核心流程其实很简单,我画个图你就明白了:

poll 系统调用工作流程 1. 准备 pollfd 数组 2. 调用 poll(fds, nfds, timeout) 3. 内核遍历所有 fd,检查事件就绪状态 4. 有事件就绪 或 超时 → 返回 5. 遍历数组,检查 revents 处理事件 关键区别 select 用位图 poll 用结构体数组 无 1024 限制

你看,流程和 select 很像,但数据结构完全不同。poll 用结构体数组代替了 select 的位图,这就带来了几个关键优势。

4.3 poll 与 select 的对比

我整理了一张对比表,把核心差异列出来:

对比维度 select poll
数据结构 fd_set 位图 pollfd 结构体数组
最大 fd 数 FD_SETSIZE(通常 1024) 无硬限制(受内存限制)
事件分离 读/写/异常三个集合 events 和 revents 分离
fd 重用 每次调用需重新设置 events 不变可重复使用
超时精度 微秒(struct timeval) 毫秒(int)
可移植性 几乎所有系统 POSIX 标准,Linux/Unix

poll 最大的改进:events 和 revents 分离。这意味着你不用像 select 那样每次调用前都重新构造 fd_set。我维护过一个长连接服务,连接数稳定在 2000 左右,用 poll 后每次循环少了一次 O(n) 的集合拷贝,性能提升很明显。

但 poll 也不是完美的。它和 select 一样,存在 「线性扫描」 的问题——每次调用都要遍历所有 fd。连接数少的时候无所谓,一旦上万,这个开销就上来了。这也是后来 epoll 出现的原因。

避坑指南:我曾经在一个网关项目里,用 poll 管理 5000 个连接。每次 poll 返回后,我傻乎乎地遍历整个数组去检查 revents。后来发现大部分 fd 根本没事件,白白浪费 CPU。优化方案是维护一个「活跃 fd 列表」,只遍历上次有事件的 fd。这个优化让 CPU 占用从 40% 降到了 8%。

4.4 poll 实战:简易 Web 服务器

光说不练假把式。我们来写一个基于 poll 的简易 Web 服务器。它能处理多个客户端连接,返回一个简单的 HTTP 响应。

#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 MAX_CLIENTS 1024
#define BUFFER_SIZE 4096

int main() {
    int listen_fd, conn_fd;
    struct sockaddr_in server_addr;
    struct pollfd fds[MAX_CLIENTS + 1];
    int nfds = 1;  // 当前管理的 fd 数量
    int i, ret;

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

    // 设置 SO_REUSEADDR,避免 TIME_WAIT 问题
    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(8080);

    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("Web server running on port 8080...\n");

    // 4. 初始化 pollfd 数组
    memset(fds, 0, sizeof(fds));
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;

    // 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, NULL, NULL);
            if (conn_fd < 0) {
                perror("accept");
                continue;
            }

            printf("New connection: fd=%d\n", conn_fd);

            // 将新连接加入 pollfd 数组
            if (nfds < MAX_CLIENTS + 1) {
                fds[nfds].fd = conn_fd;
                fds[nfds].events = POLLIN;
                nfds++;
            } else {
                printf("Too many clients, closing new connection\n");
                close(conn_fd);
            }
        }

        // 检查所有客户端 fd
        for (i = 1; i < nfds; i++) {
            if (fds[i].revents & POLLIN) {
                char buffer[BUFFER_SIZE];
                int n = read(fds[i].fd, buffer, sizeof(buffer) - 1);
                
                if (n <= 0) {
                    // 连接关闭或出错
                    if (n == 0) {
                        printf("Client fd=%d closed\n", fds[i].fd);
                    } else {
                        perror("read");
                    }
                    close(fds[i].fd);
                    fds[i].fd = -1;  // 标记为无效
                    continue;
                }

                buffer[n] = '\0';
                printf("Received from fd=%d: %s", fds[i].fd, buffer);

                // 发送 HTTP 响应
                const char *response = 
                    "HTTP/1.1 200 OK\r\n"
                    "Content-Type: text/html\r\n"
                    "Content-Length: 48\r\n"
                    "\r\n"
                    "<html><body><h1>Hello from poll!</h1></body></html>";
                
                write(fds[i].fd, response, strlen(response));
                
                // 短连接,发送完就关闭
                close(fds[i].fd);
                fds[i].fd = -1;
            }
        }

        // 清理无效的 fd,压缩数组
        int j = 1;
        for (i = 1; i < nfds; i++) {
            if (fds[i].fd != -1) {
                if (j != i) {
                    fds[j] = fds[i];
                }
                j++;
            }
        }
        nfds = j;
    }

    close(listen_fd);
    return 0;
}

代码要点:

  • 我把监听 socket 放在 fds[0],客户端连接从 fds[1] 开始。这样逻辑清晰,遍历时也方便跳过监听 fd。
  • 每次 poll 返回后,先处理新连接,再处理客户端数据。这个顺序很重要——如果先处理客户端,新连接可能会被延迟接受。
  • 我用了「标记-清理」策略:关闭连接后把 fd 设为 -1,最后统一压缩数组。这样避免了在遍历过程中删除元素导致的索引错乱。

4.5 poll 的局限性

虽然 poll 比 select 好用,但它也有自己的问题。我总结了几点:

  • 线性扫描开销:每次 poll 调用,内核都要遍历整个数组。连接数越多,开销越大。
  • 用户态/内核态数据拷贝:整个 pollfd 数组需要在用户态和内核态之间拷贝。连接数上万时,这个拷贝开销很可观。
  • 不支持边缘触发:poll 只支持水平触发模式。这意味着如果事件没处理完,下次 poll 还会通知你。在某些场景下,这会导致重复通知。

嗯,这些局限性也正是 epoll 要解决的问题。不过那是下一章的内容了,今天先把 poll 吃透。

我的建议:如果你的并发连接数在几千以内,poll 完全够用。我手头有几个内部工具就是用 poll 写的,跑了三四年没出过问题。别一上来就上 epoll,简单够用才是王道。

最后留个思考题:上面的 Web 服务器代码中,我用了短连接——每次请求后直接关闭连接。如果改成长连接(Keep-Alive),代码需要做哪些改动?提示:需要区分 POLLIN 和 POLLHUP,还要处理半包问题。