深入理解 Linux `poll` 模型:`select` 的增强版
在 Linux I/O 多路复用模型中,poll
紧随 select
之后,作为其功能更强大、限制更少的继任者。虽然 select
在处理并发连接方面迈出了重要一步,但其自身的一些缺陷促使了 poll
的诞生。poll
模型同样允许单个进程同时监控多个文件描述符,等待 I/O 事件,但在文件描述符数量限制和接口使用上进行了优化。
poll
为什么比 select
更优?
select
的一个主要痛点是其对文件描述符数量的硬性限制(通常为 1024),这使得它难以应对大规模并发连接的场景(即所谓的 C10K 问题)。此外,select
每次调用都需要重新构建并拷贝 fd_set
结构,并且在返回后需要遍历整个集合来查找就绪的文件描述符,效率较低。
poll
模型解决了 select
的这些局限性:
- 无文件描述符数量限制:
poll
不受FD_SETSIZE
宏的限制,它通过一个pollfd
结构体数组来管理文件描述符,这个数组的大小可以动态调整,理论上只受限于系统内存。 - 更灵活的事件类型:
poll
使用events
和revents
字段来指定和返回事件类型,这使得事件的表示更加清晰和灵活。 - 更高效的事件通知:虽然
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 <poll.h>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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <poll.h> // 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; // 清零 reventsclient_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);// 从数组中移除错误fdfor (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);// 从数组中移除错误fdfor (int j = i; j < client_count - 1; j++) {client_fds[j] = client_fds[j+1];}client_count--;i--; // 确保检查当前位置的新fd}}}close(serv_sock);return 0;
}
如何编译和运行示例
-
保存代码: 将上述代码保存为
poll_server.c
。 -
编译: 打开终端,使用 GCC 编译器编译代码:
gcc poll_server.c -o poll_server
-
运行服务器:
./poll_server
服务器将启动并在 8080 端口监听。
-
使用客户端测试: 你可以使用
netcat
(nc) 或编写一个简单的 C 语言客户端来测试服务器。使用
netcat
:
打开另一个终端,连接到服务器:nc 127.0.0.1 8080
然后输入消息并按回车,服务器会回显你的消息。你可以打开多个终端来模拟多个客户端连接,观察
poll
如何高效地处理它们。
通过这个 poll
示例,我们可以看到它在处理大量并发连接时的优势,尤其是在避免 select
的文件描述符数量限制方面。虽然 poll
相比 select
有显著改进,但其每次调用仍然需要遍历整个 pollfd
数组来发现就绪事件,这在极端大规模并发场景下(例如几十万甚至上百万连接)依然会带来性能瓶颈。这也是后来更高效的 epoll
(Linux 特有)和 kqueue
(BSD 特有)模型出现的原因。