8、I/O多路复用(poll):poll函数原理、pollfd结构体、poll与select对比、poll服务器实现

聊完了select,咱们来看看它的“升级版”——poll。说实话,我在早期做网络编程时,select用得比较多。但后来项目规模一上来,select那点“家底”就不够看了。尤其是那个1024的文件描述符上限,真是让人头疼。有一次我负责一个高并发网关,客户端连接数轻松破千,select直接罢工。嗯,从那以后,我就开始认真研究poll了。

8.1 poll函数原理

poll的核心思想其实和select差不多——都是让内核帮我们盯着多个文件描述符,看哪个准备好了就通知我们。但poll的实现方式更优雅一些。

它的原型长这样:

#include <poll.h>

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

参数解释一下:

  • fds:指向pollfd结构体数组的指针,说白了就是你要监控的文件描述符列表
  • nfds:数组的大小,注意不是字节数,是元素个数
  • timeout:超时时间,单位是毫秒。传-1表示永远等,传0表示立即返回

返回值:成功时返回就绪的文件描述符数量;超时返回0;出错返回-1。

核心区别:poll没有select那个1024的限制。它用链表管理fd,理论上可以监控任意数量的描述符。当然,实际受系统资源限制。

8.2 pollfd结构体详解

这个结构体是poll的灵魂。我刚开始用的时候,总觉得它比select的fd_set要直观得多。

struct pollfd {
    int   fd;         /* 要监控的文件描述符 */
    short events;     /* 感兴趣的事件 */
    short revents;    /* 实际发生的事件(由内核填充) */
};

每个pollfd结构体就管一个fd。你想想看,这比select那套“位图操作”清晰多了吧?

常用的事件标志:

事件 说明 使用场景
POLLIN 有数据可读 普通socket、管道
POLLOUT 可以写入数据 发送缓冲区有空闲
POLLERR 发生错误 一般由内核设置
POLLHUP 对方关闭连接 检测客户端断开
POLLNVAL 文件描述符未打开 fd无效时出现

个人经验:我习惯在每次poll返回后,先检查revents是否包含POLLERR或POLLHUP。这两个事件容易被忽略,但处理不好会导致连接泄漏。曾经有一次线上事故,就是因为没处理POLLHUP,导致一堆死连接占着fd不释放。

8.3 poll与select对比

这两个东西经常被放在一起比较。我直接说结论:poll是select的“改良版”,但不是“完美版”。

对比维度 select poll
最大fd数 1024(默认) 无上限(受系统限制)
数据结构 位图(fd_set) 链表(pollfd数组)
事件注册方式 每次调用都要重新设置 只需设置一次events
就绪fd查找 遍历所有fd(O(n)) 遍历所有fd(O(n))
跨平台性 几乎所有平台 POSIX系统
超时精度 微秒级 毫秒级

你看,poll在fd数量上完胜select。但遍历效率上,两者半斤八两——都是O(n)。为什么会这样?因为内核不知道哪个fd就绪了,只能一个个检查。

避坑指南:我曾经在项目中用poll监控了5000个连接,结果每次poll返回后,遍历整个数组花了大量CPU。后来才意识到,poll虽然突破了1024限制,但性能瓶颈从“fd数量”转移到了“遍历开销”。所以,连接数上万时,还是得用epoll。

8.4 poll服务器实现

光说不练假把式。咱们写一个简单的poll服务器,看看实际怎么用。

#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 PORT 8888

int main() {
    int listen_fd, conn_fd;
    struct sockaddr_in server_addr;
    struct pollfd fds[MAX_CLIENTS];
    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);
    }

    // 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");
        exit(1);
    }

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

    // 4. 初始化pollfd数组
    memset(fds, -1, sizeof(fds));
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;  // 只关心可读事件

    printf("Poll server started on port %d\n", PORT);

    // 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;
            }

            // 找个空位放新连接
            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;
                    printf("New client connected, fd=%d\n", conn_fd);
                    break;
                }
            }
            if (i == MAX_CLIENTS) {
                printf("Too many clients\n");
                close(conn_fd);
            }
        }

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

            if (fds[i].revents & POLLIN) {
                char buf[1024];
                int n = read(fds[i].fd, buf, sizeof(buf) - 1);
                if (n <= 0) {
                    // 客户端断开或出错
                    if (n == 0) {
                        printf("Client disconnected, fd=%d\n", fds[i].fd);
                    } else {
                        perror("read");
                    }
                    close(fds[i].fd);
                    fds[i].fd = -1;
                } else {
                    buf[n] = '\0';
                    printf("Received from fd=%d: %s", fds[i].fd, buf);
                    // 简单回显
                    write(fds[i].fd, buf, n);
                }
            }
        }
    }

    close(listen_fd);
    return 0;
}

这段代码的核心逻辑其实很简单:

  1. 监听socket放在fds[0],只关心POLLIN事件
  2. 新连接来了,找个空位放进去
  3. 每次poll返回后,遍历所有客户端fd,处理就绪事件
  4. 客户端断开时,关闭fd并标记为-1

我的习惯:实际项目中,我不会用这种“线性遍历”的方式管理fd数组。一般会维护一个“空闲列表”或者用链表来管理,避免每次都要从头到尾找空位。另外,我建议把fd和业务数据绑定在一起,比如用结构体数组,每个元素包含fd、缓冲区、状态等。这样代码更清晰。

8.5 poll的局限性

虽然poll比select进步了,但它也不是万能的。我总结几个实际开发中要注意的点:

  • 遍历开销:每次poll返回后,你得遍历整个数组才能知道哪些fd就绪了。连接数上万时,这个遍历本身就很耗时。
  • 内存拷贝:每次调用poll,内核要把整个pollfd数组从用户态拷贝到内核态。fd多了,这个开销也不小。
  • 水平触发:poll默认是水平触发模式。也就是说,如果某个fd一直有数据没读完,每次poll都会通知你。这可能导致“惊群”问题。

说白了,poll适合连接数在几千以内的场景。再往上,就得请出epoll了。不过那是后面章节的内容,咱们先打好poll的基础。

总结一下:poll用起来比select顺手,没有1024限制,事件管理也更直观。但它的性能瓶颈在于“遍历所有fd”和“用户态-内核态数据拷贝”。理解这些,你就能在合适的场景选对工具。

poll I/O多路复用核心逻辑 poll(fds, nfds, timeout) 内核遍历pollfd数组 有fd就绪 → 返回就绪数量 无fd就绪 → 阻塞等待 遍历fds,检查revents pollfd结构体:fd + events(用户设置) + revents(内核填充)

这张图把poll的整个流程串起来了。你看,从用户态调用poll开始,内核遍历数组,有就绪的返回,没有就阻塞。返回后用户再遍历一遍,检查每个fd的revents字段。说白了,poll把“监控”和“通知”分开了,但“遍历”这个活始终逃不掉。