云计算百科
云计算领域专业知识百科平台

Linux网络编程:深入理解poll I/O多路复用机制-->书接上文

在Linux网络编程中,如何高效地管理多个客户端连接是一个核心问题。早期使用select,但它有诸多限制,于是poll应运而生。本文将带你全面了解poll的工作原理、使用方法,并与select进行对比,帮助你在实际开发中做出正确选择。


一、为什么需要poll?

当我们编写一个TCP服务器时,通常需要同时处理多个客户端套接字。最简单的做法是使用多线程或多进程,但每个连接都创建一个线程或进程会消耗大量系统资源,尤其在高并发场景下不堪重负。

I/O多路复用技术允许一个进程同时监视多个文件描述符,当其中任何一个准备好进行I/O操作时,进程就会得到通知。select是早期的解决方案,但它存在两个致命缺陷:

  • 最大文件描述符数量限制:通常为1024(由FD_SETSIZE决定),无法应对大规模并发。

  • 效率低下:每次调用都需要将整个fd_set从用户态拷贝到内核态,且内核采用轮询方式扫描所有描述符,随着数量增加性能急剧下降。

poll正是为了改进select而生,它消除了文件描述符上限,并采用更灵活的数据结构,为后续的epoll铺平了道路。


二、poll函数详解

1. 函数原型

#include <poll.h>

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

2. 参数说明

  • fds:指向struct pollfd数组的指针,该数组描述了要监视的所有文件描述符及其事件。

  • nfds:数组中元素的个数,即要监视的文件描述符总数。

  • timeout:超时时间(毫秒)。

    • -1:阻塞等待,直到有事件发生。

    • 0:立即返回,不阻塞。

    • >0:等待指定的毫秒数,超时则返回0。

3. 返回值

  • 成功时,返回发生事件的文件描述符个数(revents不为0的条目数)。

  • 超时返回0。

  • 出错返回-1,并设置errno。

4. pollfd结构体

poll不使用文件描述符集合fd_set(位数组)来表示要检测的文件描述符(套接字) 而是使用struct pollfd结构体数组,每个结构体成员表示一个要检测的文件描述符。

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

  • events字段是由用户设置的位掩码,常见标志有:

    • POLLIN:有数据可读

    • POLLOUT:可以写入数据

    • POLLERR:发生错误(仅出现在revents)

    • POLLRDHUP:对端关闭连接(需定义_GNU_SOURCE)

  • revents由内核填充,返回实际触发的事件,可能包含POLLERR、POLLHUP等异常事件。


三、实战:使用poll构建并发TCP服务器

下面是一个完整的poll服务器示例,它能够同时处理最多CLIENT_MAX个客户端连接。

服务器端核心逻辑

  • 创建监听套接字,绑定并监听端口。

  • 初始化pollfd数组:第一个元素放入监听套接字,其余初始化为fd = -1表示空闲。

  • 循环调用poll(),返回后遍历数组,处理就绪事件。

  • 事件处理:

    • 若为监听套接字,则accept()新连接,并将其添加到数组的空闲槽位。

    • 若为普通客户端套接字,则根据revents进行读取、写入或关闭操作。

  • 服务器端(server.c)

    #define _GNU_SOURCE 1 // 必须定义,以便使用 POLLRDHUP

    #include <sys/types.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <assert.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <errno.h>
    #include <string.h>
    #include <fcntl.h>
    #include <stdlib.h>
    #include <poll.h>
    #include <fcntl.h>
    #include <sys/poll.h>

    #define CLIENT_MAX 5 // 最大支持客户端数量

    int main(void) {
    char buff[BUFSIZ];
    int server_fd = socket(PF_INET, SOCK_STREAM, 0);

    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = htonl(INADDR_ANY);
    address.sin_port = htons(8000);

    int ret = bind(server_fd, (struct sockaddr*)&address, sizeof(address));
    if (ret < 0) {
    perror("bind socket error.\\n");
    exit(-1);
    }

    listen(server_fd, 5);

    // 初始化 pollfd 数组
    struct pollfd fds[CLIENT_MAX + 1];
    fds[0].fd = server_fd;
    fds[0].events = POLLIN | POLLERR; // 监听读事件和错误
    fds[0].revents = 0;

    int user_counter = 0; // 当前已连接的客户端数量
    for (int i = 1; i <= CLIENT_MAX; ++i) {
    fds[i].fd = -1;
    fds[i].events = 0;
    }

    while (1) {
    int cur_count = user_counter + 1; // 数组有效元素个数(服务器 + 客户端)
    printf("poll: ");
    for (int i = 0; i < cur_count; i++) {
    printf("%d ", fds[i].fd);
    }
    printf("\\n");

    ret = poll(fds, cur_count, -1); // 永久等待
    if (ret < 0) {
    printf("poll failure\\n");
    break;
    }

    // 遍历所有被监视的文件描述符
    for (int i = 0; i < cur_count; ++i) {
    // —– 1. 处理服务器套接字:有新的客户端连接 —–
    if ((fds[i].fd == server_fd) && (fds[i].revents & POLLIN)) {
    struct sockaddr_in client_address;
    int client_addrlength = sizeof(client_address);
    int connfd = accept(server_fd, (struct sockaddr*)&client_address,
    &client_addrlength);
    if (connfd < 0) {
    perror("accept failed.");
    continue;
    }
    if (user_counter >= CLIENT_MAX) {
    printf("客户端连接数已达上限:%d\\n", CLIENT_MAX);
    close(connfd);
    continue;
    }
    user_counter++;
    fds[user_counter].fd = connfd;
    fds[user_counter].events = POLLIN | POLLRDHUP | POLLERR;
    fds[user_counter].revents = 0;
    printf("添加新的客户端,现在一共监听%d个客户端\\n", user_counter);
    }
    // —– 2. 处理错误事件 —–
    else if (fds[i].revents & POLLERR) {
    printf("get an error from %d\\n", fds[i].fd);
    char errors[256];
    memset(errors, 0, sizeof(errors));
    socklen_t length = sizeof(errors);
    if (getsockopt(fds[i].fd, SOL_SOCKET, SO_ERROR, &errors, &length) < 0) {
    printf("get socket option failed\\n");
    } else {
    printf("error: %s\\n", errors);
    }
    continue;
    }
    // —– 3. 处理客户端关闭(对端挂断)—–
    else if (fds[i].revents & POLLRDHUP) {
    close(fds[i].fd);
    printf("客户端(%d)关闭\\n", fds[i].fd);
    if (i < user_counter) {
    // 用数组尾部元素覆盖当前位置,然后减少计数
    fds[i] = fds[user_counter];
    i–; // 继续检查当前位置(因为换入了新fd)
    }
    user_counter–;
    }
    // —– 4. 处理普通可读事件 —–
    else if (fds[i].revents & POLLIN) {
    int connfd = fds[i].fd;
    ret = read(connfd, buff, sizeof(buff));
    if (ret <= 0) {
    if (ret < 0) perror("read failed.");
    close(connfd);
    if (i < user_counter) {
    fds[i] = fds[user_counter];
    i–;
    }
    user_counter–;
    } else {
    printf("从客户端(%d)读到%d个字节:%s\\n", connfd, ret, buff);
    // 业务处理:返回字符串长度
    sprintf(buff, "LEN = %zd", strlen(buff));
    write(connfd, buff, strlen(buff) + 1);
    }
    }
    }
    }
    close(server_fd);
    return 0;
    }

    客户端代码

    #include <sys/types.h>
    #include <sys/socket.h>
    #include <stdio.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <string.h>

    #define BUFSIZE 1024

    int main() {
    // 创建客户端套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
    perror("socket failed.");
    exit(1);
    }

    // 设置服务器地址
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = inet_addr("127.0.0.1");
    address.sin_port = htons(8000);

    int ret = connect(sockfd, (struct sockaddr*)&address, sizeof(address));
    if (ret == -1) {
    perror("connect failed.");
    exit(1);
    }

    // 接收用户输入
    char buff[BUFSIZE];
    printf("Please input: ");
    fgets(buff, sizeof(buff), stdin);
    buff[strlen(buff) – 1] = 0; // 去掉换行符

    // 向服务器发送数据
    write(sockfd, buff, strlen(buff) + 1);

    // 读取服务器发回的数据
    read(sockfd, buff, sizeof(buff));
    printf("Received: %s\\n", buff);

    // 关闭套接字
    close(sockfd);
    return 0;
    }

    代码逐段解析

    1. 宏定义与头文件

    #define _GNU_SOURCE 1

    POLLRDHUP 是 GNU 扩展,用于检测对端是否关闭了连接(相当于 TCP 的 FIN)。必须定义此宏才能使用该事件标志。

    头文件包含了网络编程和poll所需的全部库。

    2. 全局配置

    #define CLIENT_MAX 5

    限制最大并发客户端数为 5,方便演示,实际可调大。

    3. 创建监听套接字

    int server_fd = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = htonl(INADDR_ANY);
    address.sin_port = htons(8000);
    bind(server_fd, …);
    listen(server_fd, 5);

    这部分是标准 TCP 服务器初始化,监听所有网卡(INADDR_ANY),端口 8000。

    4. 初始化 pollfd 数组

    struct pollfd fds[CLIENT_MAX + 1];
    fds[0].fd = server_fd;
    fds[0].events = POLLIN | POLLERR;
    fds[0].revents = 0;

    int user_counter = 0;
    for (int i = 1; i <= CLIENT_MAX; ++i) {
    fds[i].fd = -1;
    fds[i].events = 0;
    }

    • 数组长度为 CLIENT_MAX + 1,索引 0 固定给服务器套接字,其余给客户端。

    • 每个 pollfd 结构包含了文件描述符和感兴趣的事件。服务器只需监听 POLLIN(有连接请求)和 POLLERR(错误)。

    • 未使用的槽位置 fd = -1,表示无效,poll 会忽略。

    5. 主循环

    while (1) {
    int cur_count = user_counter + 1;
    ret = poll(fds, cur_count, -1);

    }

    • cur_count 是当前有效监视的描述符个数,即服务器(1个)+ 已连接的客户端数。

    • poll 第三个参数为 -1,表示无限等待,直到有事件发生。

    • -1 表示无限阻塞,也就是“永远等待,直到有事件发生”。

    • 当 poll 被调用时,当前线程(主循环)会暂停在这里,不会继续执行后面的任何代码。

    • 在等待期间,poll 函数不会返回,因此其后面的 for 循环根本不会被执行——因为程序没走到那一步。

    6. 事件分类处理

    (1)服务器套接字可读 → 新连接

    if ((fds[i].fd == server_fd) && (fds[i].revents & POLLIN)) {
    int connfd = accept(…);
    if (connfd < 0) continue;
    if (user_counter >= CLIENT_MAX) { close(connfd); continue; }
    user_counter++;
    fds[user_counter].fd = connfd;
    fds[user_counter].events = POLLIN | POLLRDHUP | POLLERR;
    fds[user_counter].revents = 0;
    }

    • 当服务器套接字有可读事件时,说明有客户端尝试建立连接,调用 accept 接受。

    • 如果客户端数量未达上限,将新套接字放入数组的下一个空闲位置,并设置监听事件:

      • POLLIN:接收数据

      • POLLRDHUP:检测客户端主动关闭

      • POLLERR:错误检测

    • 注意:数组索引是递增的,不会复用已关闭的槽位,除非进行压缩(见下文)。

    (2)错误事件

    else if (fds[i].revents & POLLERR) {
    char errors[256];
    socklen_t length = sizeof(errors);
    getsockopt(fds[i].fd, SOL_SOCKET, SO_ERROR, &errors, &length);
    printf("error: %s\\n", errors);
    continue;
    }

    当 revents 包含 POLLERR 时,通过 getsockopt 获取具体的错误信息并打印,然后跳过本次循环,不关闭套接字(错误可能仍可恢复)。

    (3)客户端关闭(POLLRDHUP)

    else if (fds[i].revents & POLLRDHUP) {
    close(fds[i].fd);
    printf("客户端(%d)关闭\\n", fds[i].fd);
    if (i < user_counter) {
    fds[i] = fds[user_counter];
    i–;
    }
    user_counter–;
    }

    • 当检测到对端发送 FIN 时,POLLRDHUP 被触发。

    • 关闭该套接字,并从数组中移除。为了保持数组紧凑,将最后一个有效客户端移动到当前位置,然后减少 user_counter。

    • 因为移动后 i 位置换入了新的描述符,需要 i– 以便下次循环重新检查该位置。

    (4)普通可读事件

    else if (fds[i].revents & POLLIN) {
    int connfd = fds[i].fd;
    ret = read(connfd, buff, sizeof(buff));
    if (ret <= 0) {
    // 读取出错或对端关闭,关闭套接字并移除
    close(connfd);
    if (i < user_counter) {
    fds[i] = fds[user_counter];
    i–;
    }
    user_counter–;
    } else {
    // 正常读取,处理业务(返回字符串长度)
    sprintf(buff, "LEN = %zd", strlen(buff));
    write(connfd, buff, strlen(buff) + 1);
    }
    }

    • 可读事件意味着数据到达,调用 read 读取。

    • 如果 read 返回 ≤0,表示连接异常或对方关闭,则执行关闭和移除操作,与 POLLRDHUP 类似(双保险)。

    • 否则,进行业务处理:计算接收字符串的长度并返回给客户端。


    四、poll与select的全面对比

    对比维度pollselect
    数据结构 struct pollfd 数组,每个描述符独立描述事件 fd_set 位数组,通过三个集合传递读/写/异常
    支持事件类型 丰富(包括POLLRDHUP, POLLPRI等) 仅三种:可读、可写、异常
    最大监视数 无硬性上限,受系统资源限制 通常为1024(受FD_SETSIZE限制)
    性能特征 轮询所有描述符,O(n)复杂度,n大时性能下降 同样轮询,且每次需拷贝整个集合,开销更大
    内核实现 也是轮询,但未使用位图,部分实现可能更高效 每次调用均需从用户态拷贝到内核态
    可移植性 POSIX标准,广泛支持 几乎所有Unix/Linux系统都支持

    结论:poll是select的增强版,解决了描述符上限和事件类型不足的问题,但在大规模并发场景下,轮询的本质依然导致性能瓶颈。因此,poll适合连接数在几百到几千的场合,且对实时性有一定要求(因为它支持微秒级超时,而epoll为毫秒)。


    五、poll的适用场景与注意事项

    适用场景

    • 并发连接数在几百到几千之间。

    • 对实时性要求较高(poll的timeout精度为毫秒,比epoll更细)。

    • 需要在不同平台间保持可移植性(poll是POSIX标准,而epoll是Linux特有)。

    注意事项

  • 文件描述符数量:虽然poll无上限,但受限于系统文件描述符限制(ulimit -n),需要适当调整。

  • 事件重用:每次调用poll后,events字段不会被修改,可以重复使用;而revents需及时处理。

  • 边缘触发:poll不支持边缘触发模式(只有epoll支持),只能水平触发。

  • 线程安全性:poll本身是线程安全的,但如果在多线程中动态修改fds数组,需要同步保护。


  • 六、总结

    poll作为select的改进版,在文件描述符数量限制和事件类型上做了重要优化,使其能够适应更复杂的网络编程需求。然而,它依然采用轮询机制,在连接数达到数千时效率明显下降。如果你的应用需要处理数万并发连接,那么Linux下的epoll才是终极选择。但poll因其简洁性和可移植性,在中小型项目中仍然广泛使用,值得每个开发者熟练掌握。

    通过本文,你应该已经掌握了poll的基本用法、优缺点以及适用场景。在实际开发中,根据项目规模和性能要求,合理选择I/O多路复用模型,才能构建高效稳定的网络服务。

    延伸阅读:接下来可以学习epoll,了解事件驱动的高性能方案,以及如何使用libevent等库简化开发。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Linux网络编程:深入理解poll I/O多路复用机制-->书接上文
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!