I/O多路复用(中):poll()函数原理、与select对比、基于poll的并发服务器

好,我们接着聊。上一节我把select()扒了个底朝天,你可能会想:「那玩意儿有那么多坑,难道就没有更好的办法?」

有。就是今天的主角——poll()

说实话,我早年做嵌入式网络开发时,select()用得比较多。后来转到Linux服务端开发,发现大家更爱用poll()。为什么?因为它在设计上确实比select更「体面」。今天我就带你把它吃透。

1. poll() 的核心原理

poll()select() 干的是同一件事——同时监视多个文件描述符,看哪个准备好了。但它的数据结构完全不同。

select 用的是三个位图(fd_set),而 poll 用的是结构体数组。你想想看,数组比位图灵活太多了。

1.1 函数原型

#include <poll.h>

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

三个参数,简单明了:

  • fds:指向 pollfd 结构体数组的指针
  • nfds:要监视的描述符个数
  • timeout:超时时间(毫秒)

重点是这个 struct pollfd

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

每个描述符一个结构体。你告诉内核「我想看什么事件」,内核告诉你「哪些事件发生了」。就这么简单。

关键区别:select 会修改传入的 fd_set,所以每次调用前都要重新设置。而 poll 把 events 和 revents 分开,内核只写 revents,events 保持不变。这意味着——你可以复用同一个 pollfd 数组,不用每次重建。

1.2 事件标志

常用的 events 取值:

事件说明
POLLIN有数据可读
POLLOUT可以写数据
POLLERR发生错误(仅 revents)
POLLHUP对方关闭连接(仅 revents)
POLLNVAL描述符未打开(仅 revents)

嗯,这里要注意:POLLERRPOLLHUPPOLLNVAL 你不需要在 events 里设置,它们会自动出现在 revents 中。这是 poll 比 select 更贴心的一个地方——select 里你要自己处理异常情况,poll 帮你标记好了。

2. poll() vs select():到底谁更强?

我在项目中两种都写过,说实话,没有绝对的「谁碾压谁」。但 poll 确实解决了好几个 select 的痛点。

对比项select()poll()
数据结构位图(fd_set)结构体数组(pollfd)
最大描述符数受 FD_SETSIZE 限制(通常1024)无上限(受系统内存限制)
事件分离读写异常共用位图,会修改events 和 revents 分离,不修改原值
跨平台几乎所有系统都支持POSIX 系统支持,Windows 不支持
超时精度微秒级(struct timeval)毫秒级(int)
触发方式水平触发水平触发

说白了,poll 最大的优势就是:没有 1024 的限制。我曾经维护过一个老项目,用 select 管理 2000 多个连接,每次都要改 FD_SETSIZE 重新编译内核,烦得要死。换成 poll 后,世界清净了。

我的建议:如果你在 Linux 上写新项目,而且连接数可能超过 1024,直接用 poll。如果只是几十个连接的小工具,select 也够用,代码还更简单。

3. poll() 的返回值与事件处理

返回值有三种情况:

  • 大于 0:有 revents 非 0 的描述符个数(注意,不是事件总数)
  • 等于 0:超时,没有描述符就绪
  • 等于 -1:出错,检查 errno

处理事件的典型套路:

int ret = poll(fds, nfds, timeout);
if (ret == -1) {
    perror("poll");
    exit(EXIT_FAILURE);
}
if (ret == 0) {
    printf("超时了,没有事件\n");
    continue;
}

for (int i = 0; i < nfds; i++) {
    if (fds[i].revents == 0) {
        continue;  // 这个描述符没动静
    }

    if (fds[i].revents & POLLIN) {
        // 有数据可读
    }
    if (fds[i].revents & POLLOUT) {
        // 可以写数据
    }
    if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
        // 出错了,关闭连接
        close(fds[i].fd);
        fds[i].fd = -1;  // 标记为无效
    }
}

注意我标记 fd = -1 这步。poll 遇到 fd = -1 时会直接跳过,不会报错。这是 poll 的另一个贴心设计——删除描述符时,不用像 select 那样重新组织位图,直接把 fd 设成 -1 就行。

我曾经踩过的坑:有一次我忘了检查 POLLHUP,结果对方断开连接后,poll 一直返回 POLLIN(因为 EOF 也算可读),我读到的却是 0 字节。折腾了半天才发现是对方已经关了连接。记住:一定要同时检查 POLLIN 和 POLLHUP。

4. 基于 poll 的并发服务器

好,理论说完了,咱们直接上代码。下面是一个完整的 echo 服务器,用 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 PORT 8888
#define MAX_CLIENTS 1024
#define BUFFER_SIZE 1024

int main() {
    int listen_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct pollfd fds[MAX_CLIENTS];
    char buffer[BUFFER_SIZE];
    int nfds = 1;  // 当前有效的描述符个数

    // 1. 创建监听套接字
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 设置端口复用
    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)) == -1) {
        perror("bind");
        close(listen_fd);
        exit(EXIT_FAILURE);
    }

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

    printf("服务器启动,监听端口 %d\n", PORT);

    // 4. 初始化 pollfd 数组
    fds[0].fd = listen_fd;
    fds[0].events = POLLIN;
    for (int i = 1; i < MAX_CLIENTS; i++) {
        fds[i].fd = -1;  // 标记为无效
    }

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

        // 检查监听套接字
        if (fds[0].revents & POLLIN) {
            client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
            if (client_fd == -1) {
                perror("accept");
                continue;
            }

            printf("新客户端连接: %d\n", client_fd);

            // 找个空位放新连接
            int i;
            for (i = 1; i < MAX_CLIENTS; i++) {
                if (fds[i].fd == -1) {
                    fds[i].fd = client_fd;
                    fds[i].events = POLLIN;
                    break;
                }
            }
            if (i == MAX_CLIENTS) {
                printf("客户端太多,拒绝连接\n");
                close(client_fd);
            } else {
                // 更新 nfds
                if (i >= nfds) {
                    nfds = i + 1;
                }
            }
        }

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

            if (fds[i].revents & POLLIN) {
                int n = read(fds[i].fd, buffer, sizeof(buffer) - 1);
                if (n <= 0) {
                    // 连接关闭或出错
                    if (n == 0) {
                        printf("客户端 %d 断开连接\n", fds[i].fd);
                    } else {
                        perror("read");
                    }
                    close(fds[i].fd);
                    fds[i].fd = -1;
                } else {
                    // 回显数据
                    buffer[n] = '\0';
                    printf("收到: %s", buffer);
                    write(fds[i].fd, buffer, n);
                }
            }

            // 检查错误
            if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
                printf("客户端 %d 异常断开\n", fds[i].fd);
                close(fds[i].fd);
                fds[i].fd = -1;
            }
        }
    }

    close(listen_fd);
    return 0;
}

这段代码我故意写得比较「教学风」,实际项目中你可能会加更多错误处理和日志。但核心逻辑就这些:

  1. 监听套接字放在 fds[0]
  2. 新连接来了,找个空位放进去
  3. 每次 poll 返回后,遍历数组处理事件
  4. 连接关闭或出错,把 fd 设成 -1

5. poll 的局限性

虽然 poll 比 select 好,但它也不是完美的。我用了几年 poll,发现几个问题:

  • 遍历开销:每次都要遍历整个数组,哪怕只有几个活跃连接。连接数上万时,这个 O(n) 的遍历就很疼了。
  • 内存拷贝:每次调用 poll,内核要把整个 pollfd 数组从用户态拷贝到内核态。连接多的时候,这个开销不小。
  • 水平触发:和 select 一样,如果数据没读完,下次 poll 还会通知你。这本身不是问题,但如果你处理不当,容易陷入忙等。

所以后来 Linux 出了 epoll,专门解决这些问题。下一节我会详细讲 epoll,它才是真正的高并发利器。

6. 本章小结

来,我们捋一下 poll 的核心要点:

  • 用结构体数组代替位图,没有 1024 限制
  • events 和 revents 分离,不用每次重建
  • fd = -1 表示跳过,删除描述符很方便
  • 超时单位是毫秒,比 select 的微秒级粗糙一点
  • 仍然是水平触发,和 select 一样

我个人觉得,poll 是 select 的一个「体面升级版」。它没有革命性的变化,但把 select 那些让人头疼的小毛病都修了。如果你还在用 select 管理几百个连接,不妨试试 poll,你会觉得舒服很多。

好,poll 就讲到这里。代码你可以直接拿去跑,改改端口就能用。有问题随时问我。


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