深入理解 Linux `poll` 模型:`select` 的增强版

在 Linux I/O 多路复用模型中,poll 紧随 select 之后,作为其功能更强大、限制更少的继任者。虽然 select 在处理并发连接方面迈出了重要一步,但其自身的一些缺陷促使了 poll 的诞生。poll 模型同样允许单个进程同时监控多个文件描述符,等待 I/O 事件,但在文件描述符数量限制和接口使用上进行了优化。

poll 为什么比 select 更优?

select 的一个主要痛点是其对文件描述符数量的硬性限制(通常为 1024),这使得它难以应对大规模并发连接的场景(即所谓的 C10K 问题)。此外,select 每次调用都需要重新构建并拷贝 fd_set 结构,并且在返回后需要遍历整个集合来查找就绪的文件描述符,效率较低。

poll 模型解决了 select 的这些局限性:

  1. 无文件描述符数量限制poll 不受 FD_SETSIZE 宏的限制,它通过一个 pollfd 结构体数组来管理文件描述符,这个数组的大小可以动态调整,理论上只受限于系统内存。
  2. 更灵活的事件类型poll 使用 eventsrevents 字段来指定和返回事件类型,这使得事件的表示更加清晰和灵活。
  3. 更高效的事件通知:虽然 poll 同样需要遍历文件描述符数组来查找就绪的事件,但由于不再需要每次都重新构建并拷贝整个位图,其效率有所提升。

poll 的核心工作原理

poll 函数通过一个 struct pollfd 结构体数组来工作。数组中的每个元素都对应一个待监控的文件描述符及其关注的事件。

struct pollfd 结构体定义如下:

struct pollfd {
    int fd;        // 待监控的文件描述符
    short events;  // 关注的事件
    short revents; // 实际发生的事件
};
  • fd: 要监控的文件描述符。
  • events: 一个位掩码,表示你希望监控的事件类型。常见的事件标志包括:
    • POLLIN: 文件描述符可读(有数据可读)。
    • POLLPRI: 有紧急数据可读。
    • POLLOUT: 文件描述符可写(可以写入数据)。
    • POLLRDHUP: 对端连接关闭或半关闭(仅限 Linux 2.6.17+)。
    • POLLERR: 发生错误。
    • POLLHUP: 对端连接挂起(通常表示对端关闭)。
    • POLLNVAL: 无效的文件描述符。
  • revents: 由内核填充的位掩码,表示实际发生的事件。它会包含在 events 中指定的一些事件,以及可能发生的其他事件(如 POLLERR, POLLHUP, POLLNVAL)。

当调用 poll 函数时,它会阻塞直到:

  • pollfd 数组中有一个或多个文件描述符上发生了关注的事件。
  • 指定的超时时间到达。
  • 一个信号被捕获。

函数返回后,应用程序可以遍历 pollfd 数组,检查每个元素的 revents 字段来确定哪些文件描述符已经就绪,并执行相应的 I/O 操作。

poll 函数原型

#include 

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds: 指向 struct pollfd 数组的指针,其中包含要监控的文件描述符及其事件信息。
  • nfds: fds 数组中元素的数量,表示要监控的文件描述符的总数。
  • timeout: poll 函数的超时时间,单位为毫秒。
    • -1: poll 会一直阻塞直到有事件发生。
    • 0: poll 会立即返回,不阻塞(非阻塞轮询)。
    • 正整数: poll 会阻塞指定的时间(毫秒),或直到有事件发生。
  • 返回值:
    • 成功时,返回就绪文件描述符的总数。
    • 超时时,返回 0。
    • 失败时,返回 -1 并设置 errno

poll 模型示例:简单的 TCP 服务器

下面是一个使用 poll 模型实现的简单 TCP 服务器示例。这个服务器可以同时处理多个客户端连接,并在接收到客户端消息后将其回显。

#include 
#include 
#include 
#include 
#include 
#include 
#include  // poll 函数头文件

#define BUF_SIZE 1024
#define PORT 8080
#define MAX_CLIENTS 30 // 假设最大客户端数量

int main() {
    int serv_sock;
    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size;
    char buf[BUF_SIZE];
    int str_len;

    struct pollfd client_fds[MAX_CLIENTS + 1]; // 包含服务器套接字和客户端套接字
    int client_count = 0; // 当前连接的客户端数量

    // 1. 创建服务器套接字
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1) {
        perror("socket() error");
        exit(1);
    }

    // 允许地址重用,避免 TIME_WAIT 状态导致端口占用
    int optval = 1;
    setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

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

    if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind() error");
        exit(1);
    }

    // 3. 监听连接
    if (listen(serv_sock, 5) == -1) {
        perror("listen() error");
        exit(1);
    }

    // 初始化 pollfd 数组
    // 将服务器套接字加入到监控列表的第一个位置
    client_fds[0].fd = serv_sock;
    client_fds[0].events = POLLIN; // 关注可读事件(即新连接请求)
    client_count = 1; // 当前监控的文件描述符数量

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

    while (1) {
        // 4. 调用 poll 监听文件描述符
        // timeout = -1 表示无限期等待
        int fd_num = poll(client_fds, client_count, -1);
        if (fd_num == -1) {
            perror("poll() error");
            break;
        }

        // 5. 遍历就绪的文件描述符
        for (int i = 0; i < client_count; i++) {
            if (client_fds[i].revents & POLLIN) { // 检查是否有可读事件
                if (client_fds[i].fd == serv_sock) { // 服务器套接字就绪,有新连接请求
                    if (client_count >= MAX_CLIENTS + 1) { // 检查是否达到最大客户端数
                        printf("Max clients reached, new connection rejected.\n");
                        int temp_sock = accept(serv_sock, NULL, NULL); // 接收但立即关闭
                        if (temp_sock != -1) close(temp_sock);
                        continue;
                    }

                    int clnt_sock;
                    clnt_addr_size = sizeof(clnt_addr);
                    clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
                    if (clnt_sock == -1) {
                        perror("accept() error");
                        continue;
                    }

                    // 将新的客户端套接字加入到监控列表
                    client_fds[client_count].fd = clnt_sock;
                    client_fds[client_count].events = POLLIN; // 关注可读事件
                    client_fds[client_count].revents = 0;     // 清零 revents
                    client_count++; // 增加监控的文件描述符数量

                    printf("Connected client: %s:%d (fd: %d). Total clients: %d\n",
                           inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port), clnt_sock, client_count -1);
                } else { // 客户端套接字就绪,有数据可读
                    str_len = read(client_fds[i].fd, buf, BUF_SIZE);
                    if (str_len == 0) { // 客户端关闭连接
                        printf("Closed client (fd: %d). Total clients: %d\n", client_fds[i].fd, client_count - 2);
                        close(client_fds[i].fd);

                        // 将关闭的fd从数组中移除,用最后一个fd补位,并减少计数
                        for (int j = i; j < client_count - 1; j++) {
                            client_fds[j] = client_fds[j+1];
                        }
                        client_count--;
                        i--; // 确保检查当前位置的新fd
                    } else if (str_len > 0) {
                        buf[str_len] = '\0'; // 确保字符串以 null 结尾
                        printf("Received from fd %d: %s", client_fds[i].fd, buf);
                        write(client_fds[i].fd, buf, str_len); // 回显数据
                    } else { // read 错误
                        perror("read() error");
                        printf("Error on fd %d. Closing.\n", client_fds[i].fd);
                        close(client_fds[i].fd);
                        // 从数组中移除错误fd
                        for (int j = i; j < client_count - 1; j++) {
                            client_fds[j] = client_fds[j+1];
                        }
                        client_count--;
                        i--; // 确保检查当前位置的新fd
                    }
                }
            } else if (client_fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
                // 处理错误、挂起或无效文件描述符
                printf("Error/Hangup/Invalid FD on fd %d. Closing.\n", client_fds[i].fd);
                close(client_fds[i].fd);
                // 从数组中移除错误fd
                for (int j = i; j < client_count - 1; j++) {
                    client_fds[j] = client_fds[j+1];
                }
                client_count--;
                i--; // 确保检查当前位置的新fd
            }
        }
    }

    close(serv_sock);
    return 0;
}

如何编译和运行示例

  1. 保存代码: 将上述代码保存为 poll_server.c

  2. 编译: 打开终端,使用 GCC 编译器编译代码:

    gcc poll_server.c -o poll_server
    
  3. 运行服务器:

    ./poll_server
    

    服务器将启动并在 8080 端口监听。

  4. 使用客户端测试: 你可以使用 netcat (nc) 或编写一个简单的 C 语言客户端来测试服务器。

    使用 netcat:
    打开另一个终端,连接到服务器:

    nc 127.0.0.1 8080
    

    然后输入消息并按回车,服务器会回显你的消息。你可以打开多个终端来模拟多个客户端连接,观察 poll 如何高效地处理它们。

通过这个 poll 示例,我们可以看到它在处理大量并发连接时的优势,尤其是在避免 select 的文件描述符数量限制方面。虽然 poll 相比 select 有显著改进,但其每次调用仍然需要遍历整个 pollfd 数组来发现就绪事件,这在极端大规模并发场景下(例如几十万甚至上百万连接)依然会带来性能瓶颈。这也是后来更高效的 epoll(Linux 特有)和 kqueue(BSD 特有)模型出现的原因。

你可能感兴趣的:(异步编程,并发编程,C++,linux,网络编程,并发编程)