linux学习进展 I/O复用函数——poll详解
在前几篇笔记中,我们学习了I/O复用的基础概念以及select函数的使用,了解到select通过监视多个文件描述符的读写状态,实现了单进程处理多I/O事件的需求。但select存在明显的局限性,比如最大文件描述符数量限制、参数传递繁琐、内核与用户态拷贝开销随描述符数量增加而增大等问题。本节课我们将学习另一个常用的I/O复用函数——poll,它在select的基础上进行了优化,解决了部分痛点,是Linux系统中I/O复用编程的重要工具。
一、poll函数的核心作用
poll函数与select函数的核心功能一致,均用于监视多个文件描述符的状态变化,等待其中任意一个或多个文件描述符就绪(可读、可写或异常),然后通知进程进行相应的I/O操作。其本质也是通过轮询的方式检测文件描述符状态,但在接口设计和功能上对select进行了改进,让编程更便捷、扩展性更好。
与select相比,poll的核心优势的是:没有最大文件描述符数量的限制(虽然数量过大后性能会线性下降),且无需像select那样维护多个文件描述符集合,接口使用更简洁高效。同时,poll和select存在一个共同的缺点:包含大量文件描述符的数组会被整体复制于用户态和内核的地址空间之间,开销随文件描述符数量的增加而增大,且返回后需要轮询所有描述符才能找到就绪的描述符。
二、poll函数的原型与参数解析
2.1 函数原型
使用poll函数前,需包含头文件#include <sys/poll.h>,其函数原型如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);2.2 参数详解
poll函数的三个参数分别负责指定监视的文件描述符、描述符数量和等待超时时间,其中第一个参数是核心,下面逐一解析:
(1)struct pollfd *fds:监视的文件描述符集合
fds是一个指向struct pollfd结构体数组的指针,每个结构体对应一个被监视的文件描述符,以及该描述符需要监视的事件和实际发生的事件。struct pollfd结构体的定义如下:
struct pollfd { int fd; // 被监视的文件描述符 short events; // 要监视的事件(由用户设置) short revents; // 实际发生的事件(由内核设置,用于返回结果) };对结构体的三个成员详细说明:
fd:需要监视的文件描述符,比如socket、标准输入(STDIN_FILENO)、文件描述符等。如果fd被设置为负数,则该结构体将被忽略,其revents成员会被置为0。
events:用户指定的、需要监视的事件,是一个位图(通过位或运算组合多个事件),常用的事件标志如下(重点记忆前3个): 注意:POLLERR、POLLHUP、POLLNVAL这三个事件无需在events中设置,内核会自动检测并将其写入revents中,用户只需在返回后检查revents即可。
POLLIN:普通或优先级带数据可读(对应select的读事件,最常用);
POLLOUT:普通数据可写(对应select的写事件,最常用);
POLLERR:文件描述符发生错误(无需用户设置,内核会自动检测并设置到revents中);
POLLHUP:文件描述符挂起(如对方关闭连接,无需用户设置);
POLLPRI:有紧迫数据可读(如带外数据);
POLLNVAL:文件描述符非法(如fd未打开,无需用户设置)。
revents:内核返回的、实际发生的事件,也是一个位图。poll函数返回后,用户通过检查该成员,判断对应fd发生了哪些事件。例如,若revents & POLLIN为真,则表示该fd可读;若revents & POLLERR为真,则表示该fd发生错误。
(2)nfds_t nfds:监视的文件描述符数量
nfds用于指定fds数组中有效元素的个数(即需要监视的文件描述符总数),类型为nfds_t(本质是无符号整型)。注意,这里不是数组的大小,而是实际要监视的fd的数量,避免内核不必要的遍历。
(3)int timeout:超时时间(单位:毫秒)
timeout用于指定poll函数的等待时间,有三种取值情况,与select的timeout逻辑类似,但单位不同(select是微秒,poll是毫秒):
timeout > 0:等待timeout毫秒,若期间有fd就绪,立即返回;若超时无fd就绪,返回0;
timeout = 0:不阻塞,立即返回,无论是否有fd就绪(用于非阻塞轮询);
timeout = -1:无限阻塞,直到至少有一个fd就绪或被信号中断,才返回。
2.3 返回值解析
poll函数的返回值有三种情况,直接决定了后续的程序逻辑:
返回值 > 0:成功,返回revents域不为0的文件描述符的个数(即就绪的fd数量);
返回值 = 0:超时,在指定的timeout时间内,没有任何fd就绪;
返回值 = -1:失败,此时会设置errno,常见的错误码如下:
EBADF:一个或多个结构体中指定的文件描述符无效(如fd未打开);
EFAULT:fds指针指向的地址超出进程的地址空间;
EINTR:请求的事件发生前,进程被信号中断(可重新发起调用);
EINVAL:nfds参数超出系统限制(PLIMIT_NOFILE);
ENOMEM:可用内存不足,无法完成请求。
三、poll函数的使用流程(核心步骤)
使用poll函数实现I/O复用,遵循固定的流程,比select更简洁,步骤如下:
定义struct pollfd数组,并初始化每个元素的fd和events(指定要监视的fd和事件);
调用poll函数,传入fds数组、监视的fd数量nfds和超时时间timeout;
判断返回值,处理不同情况:
返回-1:处理错误(如被信号中断可重试);
返回0:超时处理(如重新轮询或退出);
返回>0:遍历fds数组,检查每个元素的revents,判断fd是否就绪,然后进行对应的I/O操作(读、写等)。
循环重复步骤2-3,持续监视fd状态(实现持续的I/O复用)。
注意:poll函数返回后,struct pollfd数组的内容不会被清空,但revents的值会被内核覆盖,因此下次调用poll前,无需重新初始化fd和events(除非需要修改监视的事件或fd)。
四、实战案例:使用poll实现简单的多客户端监听
下面通过一个简单的案例,演示poll函数的使用:实现一个TCP服务器,使用poll监视监听socket和已连接的客户端socket,处理客户端的连接请求和数据读取。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/poll.h> #include <string.h> #define MAX_CLIENT 100 // 最大客户端数量 #define BUF_SIZE 1024 // 缓冲区大小 int main() { // 1. 创建监听socket int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket error"); exit(EXIT_FAILURE); } // 2. 设置socket选项,允许端口复用 int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 3. 绑定地址和端口 struct sockaddr_in serv_addr; memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡 serv_addr.sin_port = htons(8888); // 绑定8888端口 if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) { perror("bind error"); exit(EXIT_FAILURE); } // 4. 开始监听 if (listen(listen_fd, 5) == -1) { perror("listen error"); exit(EXIT_FAILURE); } printf("服务器启动,监听端口8888...\n"); // 5. 初始化pollfd数组:第一个元素监视监听socket,其余监视客户端socket struct pollfd fds[MAX_CLIENT + 1]; // +1 用于监听socket int nfds = 1; // 初始只监视监听socket // 初始化监听socket的pollfd fds[0].fd = listen_fd; fds[0].events = POLLIN; // 监视读事件(等待客户端连接) fds[0].revents = 0; // 初始化客户端socket的pollfd(初始化为-1,表示未使用) for (int i = 1; i <= MAX_CLIENT; i++) { fds[i].fd = -1; fds[i].events = 0; fds[i].revents = 0; } // 6. 循环调用poll,处理客户端连接和数据 while (1) { // 调用poll,无限阻塞(timeout=-1) int ret = poll(fds, nfds, -1); if (ret == -1) { perror("poll error"); continue; // 被信号中断后重试 } else if (ret == 0) { continue; // 超时(此处不会发生,因为timeout=-1) } // 7. 遍历pollfd数组,处理就绪的fd for (int i = 0; i < nfds; i++) { // 跳过无效的fd(fd=-1) if (fds[i].fd == -1) { continue; } // 判断是否有读事件就绪 if (fds[i].revents & POLLIN) { // 情况1:监听socket就绪,处理客户端连接 if (fds[i].fd == listen_fd) { struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); // 接受客户端连接 int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len); if (client_fd == -1) { perror("accept error"); continue; } // 将新的客户端fd加入pollfd数组 int j; for (j = 1; j <= MAX_CLIENT; j++) { if (fds[j].fd == -1) { fds[j].fd = client_fd; fds[j].events = POLLIN; // 监视客户端的读事件 fds[j].revents = 0; break; } } // 检查客户端数量是否已满 if (j > MAX_CLIENT) { printf("客户端数量已满,拒绝连接\n"); close(client_fd); } else { // 更新nfds(确保覆盖所有有效fd) if (j >= nfds) { nfds = j + 1; } printf("客户端连接成功:%s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); } } // 情况2:客户端socket就绪,读取客户端数据 else { char buf[BUF_SIZE] = {0}; int read_len = read(fds[i].fd, buf, BUF_SIZE - 1); if (read_len == -1) { perror("read error"); close(fds[i].fd); fds[i].fd = -1; // 标记为无效fd continue; } else if (read_len == 0) { // 客户端关闭连接 printf("客户端断开连接\n"); close(fds[i].fd); fds[i].fd = -1; continue; } // 打印客户端发送的数据 printf("收到客户端数据:%s\n", buf); // 简单回显:将数据发送回客户端 write(fds[i].fd, buf, read_len); } } // 判断是否有错误事件 if (fds[i].revents & POLLERR) { printf("文件描述符%d发生错误\n", fds[i].fd); close(fds[i].fd); fds[i].fd = -1; } } } // 关闭监听socket(实际不会执行到这里) close(listen_fd); return 0; }案例解析
1. 初始化时,pollfd数组的第一个元素用于监视监听socket,事件设置为POLLIN(等待客户端连接);其余元素初始化为fd=-1,表示未使用。
2. 循环调用poll函数,无限阻塞等待fd就绪,当有fd就绪时,遍历数组检查每个fd的revents。
3. 若监听socket就绪(POLLIN),则接受客户端连接,将新的客户端fd加入pollfd数组,设置监视其读事件。
4. 若客户端socket就绪(POLLIN),则读取客户端数据并回显,若读取到0字节(客户端关闭连接)或读错误,则关闭fd并标记为无效。
5. 若检测到POLLERR事件(fd错误),则关闭fd并标记为无效。
五、poll函数的优缺点总结
5.1 优点
无最大文件描述符数量限制:相比select的1024(受FD_SETSIZE限制),poll可以监视更多的fd(数量由系统内存决定,但数量过大性能会下降);
接口更简洁:无需维护多个文件描述符集合(读、写、异常),只需一个pollfd数组,事件通过结构体成员设置和返回,逻辑更清晰;
事件区分更明确:通过revents返回实际发生的事件,无需像select那样重新检查所有fd的状态,且自动检测错误、挂起等事件。
5.2 缺点
轮询效率低:与select一样,poll返回后,需要遍历整个pollfd数组才能找到就绪的fd,当fd数量较多时,效率会线性下降;
内核与用户态拷贝开销:每次调用poll,都需要将整个pollfd数组从用户态拷贝到内核态,当fd数量较多时,拷贝开销较大;
无法直接知道哪些fd就绪:必须遍历数组,不像后续的epoll可以直接获取就绪的fd列表。
六、poll与select的对比
对比项 | select | poll |
|---|---|---|
最大fd限制 | 有(默认1024,受FD_SETSIZE限制) | 无(由系统内存决定) |
fd集合管理 | 三个独立集合(读、写、异常),需手动重置 | 一个pollfd数组,无需重置(除非修改事件) |
事件传递方式 | 参数-值传递,返回后需重新检查fd状态 | 结构体成员(events/revents),返回后直接检查revents |
效率 | fd数量多时,效率低(需遍历集合) | fd数量多时,效率同样低(需遍历数组),但略优于select |
拷贝开销 | 每次调用拷贝三个fd集合 | 每次调用拷贝整个pollfd数组 |
七、学习小结与注意事项
1. poll函数是select的改进版,核心解决了select的最大fd限制问题,接口更简洁,但仍存在轮询效率低、拷贝开销大的问题,适用于fd数量适中的场景。
2. 使用poll时,需注意:fd设置为-1时,该pollfd结构体被忽略;revents中的错误、挂起事件无需用户设置,内核会自动检测。
3. poll函数返回后,revents会被内核覆盖,而fd和events不会被清空,下次调用时可直接复用(除非需要修改监视的fd或事件)。
4. 当fd数量较多(如成千上万)时,poll和select的效率都会严重下降,此时应使用更高效的I/O复用机制——epoll(下一篇笔记将详细讲解)。
5. 实战中,若遇到poll调用失败(返回-1),需判断errno,若为EINTR(被信号中断),可重新调用poll,避免程序退出。
通过本节课的学习,我们掌握了poll函数的原型、参数、使用流程和实战用法,理解了它与select的区别和优缺点。下一节课,我们将学习Linux中最常用、最高效的I/O复用函数——epoll,彻底解决poll和select的性能痛点。
