I/O 多路复用select,poll
目录
I/O多路复用的介绍
多进程/多线程模型的弊端
网络多路复用如何解决问题?
网络多路复用的常见实现方式
常见的开源网络库
select详细介绍
select函数介绍
套接字可读事件,可写事件,异常事件
fd_set类型介绍
select的两次拷贝,两次遍历
select使用示例介绍
select服务器示例代码
poll函数详细介绍
poll函数介绍
pollfd类型介绍
poll的工作原理
poll的优缺点
I/O多路复用的介绍
多进程/多线程模型的弊端
在上一篇文章中我们详细介绍了Linux中的网络编程,使用相关API实现了多进程/多线程模型,即:
Linux网络编程-CSDN博客
之前的客户端—服务器端连接处理思路:每当有一个新的客户端连接,服务器就创建一个新的进程或线程来处理它。我们之前的示例中在新创建的进程中还会使用fork来进行进一步创建一个进程,用来实现读写分离。
这样就相当于每一个客户端连接服务器,就需要多创建两个进程来实现客户端与服务器端的通信。
弊端:
-
资源消耗大:每个进程或线程都需要独立的内存空间(栈、堆等),并维护自己的上下文信息。大量的进程/线程会迅速耗尽系统内存。
-
上下文切换开销:操作系统在这么多进程/线程之间切换 CPU 时,会产生大量的上下文切换开销,这会严重降低 CPU 的有效工作时间。
-
文件描述符限制:每个进程/线程都会占用一个文件描述符。系统对单个进程或整个系统的文件描述符数量有上限,容易达到瓶颈。
网络多路复用如何解决问题?
网络多路复用允许单个进程/线程同时监控多个文件描述符(包括套接字)。当任何一个文件描述符准备好进行 I/O 操作(例如,有数据可读或可以写入数据)时,多路复用机制会通知应用程序。
这样,你的服务器就不需要为每个客户端都创建一个独立的进程或线程了。一个工作进程/线程就能高效地管理数百甚至上万个并发连接。
网络多路复用的常见实现方式
网络多路复用的常见实现方式主要有三种:select、poll 和 epoll (Linux 特有)。它们都允许一个进程或线程同时监控多个文件描述符(包括网络套接字),但具体机制和性能特点有所不同。
常见的开源网络库
在实际开发中,我们经常会选择使用成熟的开源网络库或框架来构建高性能的并发服务器,而并不是会选择使用select,poll,epoll这些来进行构建。
在 C++ 中,有很多优秀的开源网络库可以帮助你高效地开发网络应用程序。这些库封装了底层操作系统的网络 API(如 Linux 上的 epoll
,macOS 上的 kqueue
,Windows 上的 IOCP),提供了更高级、更易用的接口,并且通常具备高性能、跨平台和丰富的功能。
下面介绍几个 C++ 中常用的开源网络库:
Boost.Asio
Boost.Asio 是一个功能强大、设计精良的 C++ 异步 I/O 库,是现代 C++ 网络编程的首选。
它提供了一套统一的接口来处理各种异步 I/O 操作(包括网络套接字、定时器、串口等)。其核心是
io_context
(或io_service
),一个事件循环,用于分发 I/O 事件。主要特点:
C++ 风格:与 C++ 标准库和现代 C++ 特性(如模板、协程)高度融合,代码更符合 C++ 习惯。
功能全面:不仅处理网络通信,还支持定时器、信号等多种 I/O。
跨平台:底层自动适配不同操作系统的高性能 I/O 多路复用机制(如 Linux 的
epoll
)。灵活性高:支持同步和异步编程模型,以及多种并发模式。
libevent 和 libev
libevent 和 libev 是轻量级、事件驱动的 C 语言网络库,专注于高性能的事件通知。 (libev 是 libevent 的一个更小、更快的替代品,设计理念类似)。
它们的核心是事件循环 (event loop),通过注册回调函数来处理文件描述符上的 I/O 事件、定时器事件和信号事件。
事件驱动:基于事件循环,当 I/O 事件发生时,通过回调函数通知应用程序,避免了阻塞。
轻量和高效:库本身的代码量较小,运行效率高,资源占用低。
跨平台:支持
epoll
、kqueue
、IOCP
、poll
、select
等多种 I/O 复用机制。多种事件支持:不仅支持网络 I/O 事件,还支持定时器、信号、文件 I/O 等事件。
适用场景: 适用于需要极致性能、资源受限或嵌入式环境下的网络应用开发,如高性能代理服务器、聊天服务器、游戏服务器等。它们是构建自己的高性能网络框架的理想基石。
使用开源网络库的好处
- 简化开发:提供了抽象层,你不需要直接操作 epoll_create、epoll_ctl、epoll_wait 等底层函数。
- 提高效率:这些库通常由经验丰富的开发者优化过,性能经过严格测试,并解决了许多难以发现的 bug 和边界条件。
- 跨平台支持:许多流行的库支持跨平台,底层会自动根据操作系统选择合适的 I/O 多路复用机制(epoll、kqueue、IOCP 等)。
- 丰富的功能:除了基本的 I/O 封装,它们往往还集成了定时器、线程池、内存管理、日志、协议编解码等常用功能。
select详细介绍
select函数介绍
select通过轮询的方式检查一组文件描述符的状态,判断它们是否准备好进行 I/O 操作。
函数原型
#include <sys/select.h>
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds,fd_set *restrict exceptfds, struct timeval *restrict timeout
);
参数介绍
nfds:所有文件描述符集合中最大的文件描述符值加 1。select 内部会从 0 到 nfds-1 遍历这些文件描述符。
readfds:指向一个 fd_set 结构体的指针,用于监听可读事件的文件描述符集合。如果不需要监听可读事件,可以设置为 NULL。
writefds:指向一个 fd_set 结构体的指针,用于监听可写事件的文件描述符集合。如果不需要监听可写事件,可以设置为 NULL。
exceptfds:指向一个 fd_set 结构体的指针,用于监听异常事件的文件描述符集合。如果不需要监听异常事件,可以设置为 NULL。
timeout:指向一个 struct timeval 结构体的指针,用于设置 select 的超时时间。
- 如果为 NULL,select 将一直阻塞直到有文件描述符就绪。
- 如果指向一个 struct timeval 结构体,且其成员 tv_sec 和 tv_usec 都为 0,select 将立即返回,不阻塞(非阻塞轮询)。
- 如果指向一个 struct timeval 结构体,且其成员 tv_sec 或 tv_usec 大于 0,select 将阻塞直到超时时间到达或有文件描述符就绪。
返回值:
- 成功时,返回就绪的文件描述符的数量。
- 超时时,返回 0。
- 失败时,返回 -1,并设置 errno。
select返回值表示设置了多少位,这个位数是可读事件+可写事件+异常事件的总和
select 的返回值是 所有就绪的文件描述符的总数,无论是可读、可写还是异常事件就绪。它不会区分这些事件的类型,只是告诉你“有这么多 FD 就绪了”。
例如,如果 sock1 可读,sock2 可写,sock3 有异常,那么 select 将返回 3。
套接字可读事件,可写事件,异常事件
上面我们说到了select可以用来监控socket套接字集合的可读事件,可写事件,异常事件。
那么这三种事件究竟是什么呢?套接字在什么情况下会产生这些事件呢?
我们必须要搞清楚这三种事件,理解 select 何时会认为一个socket套接字的文件描述符是“可读”或“可写”是正确使用它的必要前提。
select中的可读事件(检测可读事件最常见)
-
监听套接字 (Listening Socket): 如果监听套接字上发生了新的连接请求(即有客户端尝试连接服务器),它就会变得可读。此时,你可以调用
accept()
来接受新的连接。
-
连接套接字 (Connected Socket):
-
接收缓冲区中有数据可读。此时,你可以调用
read()
或recv()
来读取数据,并且这些操作通常会立即返回而不会阻塞(除非缓冲区的数据量小于你请求读取的量,在阻塞模式下仍可能阻塞,但通常会与非阻塞模式结合使用)。 -
连接被对端关闭(发送了 FIN 包)。此时,
read()
会返回 0,表示连接已正常关闭。 -
连接发生错误,导致数据不再可读。
-
select中的可写事件
-
发送缓冲区有空间: 套接字的发送缓冲区(send buffer)有足够的空间可以容纳你要发送的数据。此时,你可以调用
write()
或send()
来写入数据,并且这些操作通常会立即返回而不会阻塞。
-
connect()
完成: 对于非阻塞的connect()
调用,当连接建立成功或失败时,套接字会变得可写(或在exceptfds
中报告错误)。你需要通过getsockopt()
结合SO_ERROR
选项来获取连接的结果。
select 中的异常事件
exceptfds 用于监听“异常事件”。在实际的网络编程中,最常见(几乎是唯一)的异常事件是:
TCP 带外数据 (Out-of-Band Data, OOB):
当 TCP 套接字接收到带外数据时,它会触发一个异常事件。带外数据是一种特殊的、优先级更高的数据流,它可以绕过正常的 TCP 缓冲区,用于传输紧急信息(例如,发送“紧急”信号来中断远程操作)。
接收带外数据需要使用 recv() 并指定 MSG_OOB 标志。
fd_set类型介绍
fd_set 是一个位图(bitmap),用来表示一组文件描述符(file descriptor,简称 FD)。每个位(bit)对应一个文件描述符,如果对应的位被设置(为 1),就表示这个文件描述符在这个集合中。
fd_set 集合的操作宏
为了方便用户程序操作 fd_set 集合,标准库提供了一组宏。这些宏实际上是对底层位图操作的封装:
- FD_ZERO(fd_set *set):将 fd_set 集合中所有的位清零,即清空集合。
- FD_SET(int fd, fd_set *set):将文件描述符 fd 加入到 fd_set 集合中。
- FD_CLR(int fd, fd_set *set):将文件描述符 fd 从 fd_set 集合中移除。
- FD_ISSET(int fd, fd_set *set):检查文件描述符 fd 是否在 fd_set 集合中(即是否就绪)。
fd_set的限制
fd_set
最主要的限制是它能够容纳的文件描述符数量。这个上限由系统宏 FD_SETSIZE
定义,在大多数 Linux 系统上,其默认值通常是 1024。这意味着一个 fd_set
实例最多只能同时监听 1024 个文件描述符。
这个限制对于处理高并发连接的服务器来说是一个严重的瓶颈。当需要处理超过 1024 个客户端连接时,select
就不再适用,需要考虑使用 poll
或 epoll
等其他 I/O 多路复用机制。
fd_set 的内存与性能开销
-
内存开销:
fd_set
的大小是固定的,通常是FD_SETSIZE / 8
字节。例如,如果FD_SETSIZE
是 1024,那么fd_set
大约占用 128 字节 (1024 / 8 = 128
)。这部分内存开销通常不大。
-
性能开销(与
select
相关):-
用户空间到内核空间的拷贝: 每次调用
select
,都需要将完整的fd_set
集合从用户空间复制到内核空间。当FD_SETSIZE
较大时,即使实际活跃的 FD 很少,也需要复制整个fd_set
,这会带来不必要的开销。 -
内核遍历: 内核需要遍历
fd_set
中所有的FD_SETSIZE
个位,以检查哪些 FD 已经就绪。这个过程是 O(N) 的,其中 N 是FD_SETSIZE
的值(或nfds
的值)。 -
内核空间到用户空间的拷贝:
select
返回时,内核需要将包含就绪文件描述符的fd_set
集合(经过内核修改后的)从内核空间复制回用户空间。这同样是一次完整的fd_set
结构体的拷贝,带来了额外的开销。 -
用户空间遍历:
select
返回后,用户程序也需要遍历整个fd_set
来找出是哪个 FD 就绪,这也是一个 O(N) 的操作。
-
select的两次拷贝,两次遍历
从用户程序调用一次 select
系统调用,通常涉及到两次数据拷贝和两次遍历操作。我们来详细分解一下:
1. 第一次拷贝:用户空间到内核空间
当你调用 select(nfds, &readfds, &writefds, &exceptfds, &timeout)
时:
-
拷贝内容:
readfds
、writefds
和exceptfds
这三个fd_set
结构体(以及timeout
结构体)的完整内容会从用户空间复制到内核空间,注意这里是整个位图都会被拷贝过去,并不是根据nfds来选择部分进行拷贝。 -
原因: 内核需要知道你对哪些文件描述符的哪些事件感兴趣,以便进行监控。
2. 第一次遍历:内核空间遍历
在内核空间:
-
遍历过程: 内核会从
0
到nfds-1
遍历每一个文件描述符。对于每个文件描述符,它会检查其是否在你传入的readfds
、writefds
或exceptfds
的副本中被设置了位。 -
检查状态: 如果被设置了位,内核就会去检查这个文件描述符的实际状态(例如,网络缓冲区是否有数据,或者发送缓冲区是否有空间)。
-
结果记录: 如果文件描述符就绪,内核会在其内部的一个临时就绪
fd_set
集合中标记对应的位。
3. 第二次拷贝:内核空间到用户空间
当 select
返回时(有就绪 FD、超时或出错):
-
拷贝内容: 内核会将其内部维护的、只包含就绪文件描述符的临时就绪
fd_set
集合,复制回用户空间,覆盖掉你传入的readfds
、writefds
和exceptfds
。 -
原因: 这是
select
返回就绪信息给用户程序的方式。
4. 第二次遍历:用户空间遍历
select
返回后,在用户空间:
-
遍历过程: 用户程序需要再次从
0
到nfds-1
(或你实际感兴趣的 FD 范围) 遍历readfds
、writefds
和exceptfds
这三个被修改过的fd_set
集合。 -
检查状态: 使用
FD_ISSET(fd, &set)
宏来逐个检查是哪些文件描述符就绪了。 -
执行操作: 根据
FD_ISSET
的结果,对就绪的文件描述符执行相应的 I/O 操作(read()
,write()
,accept()
等)。
大家应该会好奇一个问题,为什么不能让程序将用户空间中的套接字位图fd_set的地址传递给内核空间呢?这样不是可以避免拷贝吗?为什么要拷贝一份数据过去,内核设置好了之后再将设置好的数据拷贝回用户空间?
主要原因有如下两点:
1.内存保护角度:隔离用户空间和内核空间: 这是更重要的隔离。内核拥有最高的权限,负责管理所有硬件资源和系统核心功能。如果用户程序能直接通过一个指针访问内核内存,或者内核能随意访问用户内存,那么:
- 安全性风险: 恶意用户程序可以修改内核数据结构,从而获得特权,甚至破坏整个系统。
- 稳定性风险: 用户程序的错误(比如空指针解引用、越界访问)可能会直接导致内核崩溃,从而引发整个系统宕机。
- 一致性问题: 如果内核直接操作用户数据,而用户程序同时也在修改这些数据,会带来复杂的数据同步和一致性问题。
2.虚拟内存差异角度:
现代操作系统都采用虚拟内存技术。
虚拟地址 vs. 物理地址: 用户程序中使用的地址都是虚拟地址。这些虚拟地址需要通过内存管理单元(MMU)映射到实际的物理地址。每个进程都有自己的页表,负责将本进程的虚拟地址映射到物理地址。
不同的地址空间: 内核运行在它自己的虚拟地址空间中,用户进程运行在它们各自的虚拟地址空间中。即使一个用户进程传递给内核一个它自己虚拟地址空间中的指针,对于内核来说,这个指针指向的虚拟地址是无效的,因为它不属于内核自己的地址空间。内核需要一套机制来“翻译”或“安全地访问”这些用户空间的地址。
内核访问用户空间数据的正确方式
既然不能直接操作,那内核如何安全地访问用户空间数据呢?答案是通过特定的安全机制和系统调用。
拷贝(Copy_From_User / Copy_To_User): 这是最常见且最安全的方式。当用户程序调用
select
或poll
这样的系统调用并传递数据(如fd_set
或pollfd
数组)时,内核会使用专门的函数(例如 Linux 内核中的copy_from_user()
和copy_to_user()
)来:
验证地址: 首先,内核会验证用户提供的地址是否合法,是否在用户进程的有效虚拟地址范围内,以及是否有足够的权限访问。
安全拷贝: 验证通过后,内核会将用户空间的数据完整地拷贝到内核空间的一块临时缓冲区中进行操作。操作完成后,再将结果拷贝回用户空间。 这种拷贝虽然有性能开销,但它确保了内核不会因为用户空间的错误而崩溃,也避免了用户程序的恶意篡改。
内存映射(Memory Mapping): 对于一些需要高性能、大量数据传输的场景(例如文件I/O、共享内存),操作系统提供了内存映射机制(如
mmap()
)。这允许用户空间和内核空间(或多个用户进程)共享同一块物理内存区域。但即使是mmap
,也需要通过系统调用来建立映射关系,并且内核会设置适当的权限和保护,确保安全。这种方式并非直接的指针传递,而是建立了一种受控的共享访问机制。
select使用示例介绍
举个例子:假设你正在编写一个服务器程序,需要同时监听客户端连接请求(通过监听套接字 listen_sock)以及已经建立的客户端连接上的数据(通过连接套接字 client_sock1, client_sock2 等)。
这个示例将创建一个简单的服务器,为了实现简单,这个示例中select只检测了socket套接字的可读事件集合 (read_fds)
在这种情况下,你需要:
- 创建一个 fd_set read_fds;
- 在每次循环开始时,调用 FD_ZERO(&read_fds);
- 将 listen_sock 和所有活动的 client_sock 使用 FD_SET 添加到 read_fds 中。
- 调用 select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
- select 返回后,首先检查 FD_ISSET(listen_sock, &read_fds)。如果是,说明有新的连接请求,可以调用 accept()。
- 然后遍历所有 client_sock,检查 FD_ISSET(client_sock_i, &read_fds)。如果是,说明这个客户端有数据可读,可以调用 read()。
- 如果某个客户端连接关闭了,就使用 FD_CLR 将其从 fd_set 中移除。
fd_set 是 select I/O 多路复用机制的核心数据结构,它以位图的形式高效地管理文件描述符集合。尽管它使用简单且具有良好的跨平台性,但其固定的 FD_SETSIZE 限制和线性扫描的效率问题,使其在高并发场景下表现不佳。理解 fd_set 的工作原理对于掌握 select 的使用至关重要
select服务器示例代码
#include <stdio.h> // For printf, perror
#include <stdlib.h> // For exit, EXIT_FAILURE
#include <string.h> // For memset, strlen
#include <unistd.h> // For close, read, write
#include <arpa/inet.h> // For sockaddr_in, inet_ntop
#include <sys/socket.h> // For socket, bind, listen, accept
#include <sys/select.h> // For select, FD_ZERO, FD_SET, FD_CLR, FD_ISSET
#include <errno.h> // For errno, EWOULDBLOCK#define PORT 8080 // 服务器监听端口
#define MAX_CLIENTS 5 // 最大支持的客户端连接数
#define BUFFER_SIZE 1024 // 数据缓冲区大小int main() {int listen_fd; // 监听套接字文件描述符int client_fds[MAX_CLIENTS]; // 存储已连接客户端的套接字文件描述符int max_fd; // select 监听的最大文件描述符 + 1int i; // 循环变量fd_set read_fds; // select 用来监听可读事件的文件描述符集合// 初始化客户端文件描述符数组,设为 -1 表示空闲for (i = 0; i < MAX_CLIENTS; i++) {client_fds[i] = -1;}// --- 1. 创建监听套接字 ---// AF_INET: IPv4协议族// SOCK_STREAM: TCP流式套接字// 0: 默认协议 (TCP)if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {perror("socket error");exit(EXIT_FAILURE);}printf("Listening socket created: %d\n", listen_fd);// 设置套接字选项:允许地址重用,防止 TIME_WAIT 状态导致端口不能立即重用int opt = 1;if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {perror("setsockopt error");close(listen_fd);exit(EXIT_FAILURE);}// --- 2. 绑定地址和端口 ---struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr)); // 清零server_addr.sin_family = AF_INET; // IPv4server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有可用网络接口server_addr.sin_port = htons(PORT); // 端口号,htons 将主机字节序转为网络字节序if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {perror("bind error");close(listen_fd);exit(EXIT_FAILURE);}printf("Socket bound to port %d\n", PORT);// --- 3. 开启监听 ---// 10: 允许的最大等待连接队列长度if (listen(listen_fd, 10) == -1) {perror("listen error");close(listen_fd);exit(EXIT_FAILURE);}printf("Server listening on port %d...\n", PORT);// --- 4. select 循环处理事件 ---while (1) {FD_ZERO(&read_fds); // 每次循环前清空文件描述符集合FD_SET(listen_fd, &read_fds); // 将监听套接字加入可读集合 (因为它可能接收新连接)// 确定当前需要监听的最大文件描述符 + 1max_fd = listen_fd;for (i = 0; i < MAX_CLIENTS; i++) {if (client_fds[i] != -1) {FD_SET(client_fds[i], &read_fds); // 将每个活跃的客户端套接字加入可读集合if (client_fds[i] > max_fd) {max_fd = client_fds[i];}}}// 调用 select 进行 I/O 多路复用,阻塞等待事件// 第一个参数是所有要监听的 FD 中的最大值加 1// 后三个参数分别代表监听可读、可写、异常事件的 FD 集合// 最后一个参数是超时时间,NULL 表示永远阻塞直到有事件发生printf("\nWaiting for events (max_fd = %d)...\n", max_fd);int activity = select(max_fd + 1, &read_fds, NULL, NULL, NULL);if ((activity < 0) && (errno != EINTR)) { // 检查 select 返回值perror("select error");break; // 出现错误则退出循环}// --- 5. 处理就绪事件 ---// (1) 检查监听套接字是否可读:表示有新的客户端连接请求if (FD_ISSET(listen_fd, &read_fds)) {struct sockaddr_in client_addr;socklen_t client_addr_len = sizeof(client_addr);// 接受新连接int new_socket = accept(listen_fd, (struct sockaddr *)&client_addr, &client_addr_len);if (new_socket == -1) {perror("accept error");continue; // 继续下一轮循环}char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);printf("New connection accepted. Socket FD: %d, IP: %s, Port: %d\n",new_socket, client_ip, ntohs(client_addr.sin_port));// 将新连接的套接字加入到 client_fds 数组中int found_slot = 0;for (i = 0; i < MAX_CLIENTS; i++) {if (client_fds[i] == -1) { // 找到一个空闲位置client_fds[i] = new_socket;found_slot = 1;printf("Adding client socket %d to array slot %d\n", new_socket, i);break;}}if (!found_slot) {printf("Max clients reached. Rejecting new connection %d\n", new_socket);close(new_socket); // 如果没有空闲位置,关闭新连接}activity--; // 减少一个已处理的活动事件}// (2) 检查已连接客户端套接字是否可读:表示有数据到来或连接关闭for (i = 0; i < MAX_CLIENTS; i++) {int client_fd = client_fds[i];if (client_fd != -1 && FD_ISSET(client_fd, &read_fds)) {char buffer[BUFFER_SIZE];memset(buffer, 0, BUFFER_SIZE); // 清空缓冲区// 从客户端读取数据ssize_t bytes_read = read(client_fd, buffer, BUFFER_SIZE - 1);if (bytes_read == 0) {// 对端关闭了连接printf("Client %d disconnected.\n", client_fd);close(client_fd); // 关闭套接字client_fds[i] = -1; // 将数组中的位置标记为空闲} else if (bytes_read == -1) {// 读取错误perror("read error");close(client_fd);client_fds[i] = -1;} else {// 成功读取到数据buffer[bytes_read] = '\0'; // 确保字符串以 null 结尾printf("Received from client %d: %s\n", client_fd, buffer);// 可选:将收到的数据回显给客户端if (write(client_fd, buffer, bytes_read) == -1) {perror("write error");}}activity--; // 减少一个已处理的活动事件}// 如果所有活动事件都已处理,可以提前退出循环if (activity == 0) {break;}}}// --- 6. 清理资源 (通常不会到达这里,除非发生严重错误) ---close(listen_fd);for (i = 0; i < MAX_CLIENTS; i++) {if (client_fds[i] != -1) {close(client_fds[i]);}}return 0;
}
poll函数详细介绍
poll函数介绍
函数原型:
#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数介绍:
fds:这是一个指向 struct pollfd 结构体数组的指针。每个 struct pollfd 结构体都代表一个我们希望监视的文件描述符及其感兴趣的事件。
nfds:这是 fds 数组中元素的个数,即我们要监视的文件描述符的总数。
timeout:这是一个整数,指定 poll 函数的等待时间(毫秒)。
- 大于0的整数: poll 将等待指定毫秒数,如果在此期间没有事件发生,poll 将返回0。
- 0: poll 不会等待,立即返回。它会检查当前文件描述符的状态,并返回已经准备好的文件描述符的数量。
- -1: poll 将无限期等待,直到有事件发生或被信号中断。
返回值
poll 函数的返回值表示就绪的文件描述符的数量,即 revents 字段非零的 struct pollfd 结构体的数量。
-
大于0: 表示有指定数量的文件描述符就绪。
-
0: 表示在 timeout 期间没有文件描述符就绪。
-
-1: 表示 poll 函数调用失败,此时可以通过 errno 变量获取具体的错误信息。
pollfd类型介绍
struct pollfd {int fd; /* 文件描述符 */short events; /* 监视的事件 */short revents; /* 实际发生的事件 */
};
-
fd
:要监视的文件描述符。 -
events
:这是一个位掩码,表示我们感兴趣的事件。可以是一个或多个事件的按位或组合。常用的事件标志包括:-
POLLIN
:文件描述符上有数据可读。 -
POLLOUT
:文件描述符上可以写入数据。 -
POLLERR
:文件描述符上发生错误。 -
POLLHUP
:对端挂断连接(通常是EOF)。 -
POLLNVAL
:无效的文件描述符请求。
-
-
revents
:这是一个位掩码,由poll
函数返回,表示在文件描述符上实际发生的事件。它的取值与events
类似,可以包含上述事件标志。
poll的工作原理
当调用 poll 函数时,内核会遍历 fds 数组中的每个 struct pollfd 结构体,检查其对应的文件描述符上是否发生了 events 中指定的事件。
- 如果事件发生,内核会在该 struct pollfd 的 revents 字段中设置相应的位,并将其标记为就绪。
- 如果没有事件发生,并且 timeout 尚未到期,poll 函数会进入睡眠状态,直到事件发生或 timeout 到期。
- 当 poll 返回时,程序可以遍历 fds 数组,检查每个 struct pollfd 的 revents 字段,以确定哪些文件描述符已经就绪,然后对这些文件描述符进行相应的I/O操作。
poll的优缺点
poll
的优点
-
没有文件描述符数量限制: 解决了
select
的FD_SETSIZE
限制问题,可以监视任意数量的文件描述符,只受限于系统内存。 -
更清晰的事件表示:
struct pollfd
结构体使得事件的设置和检查更加直观。 -
更好的性能: 尤其在文件描述符数量较多时,
poll
的性能优于select
。 -
可重用性:
fds
数组可以在多次poll
调用中重用,而select
的fd_set
每次调用后都需要重新初始化。 -
只拷贝实际监视的
nfds
个struct pollfd
结构体,数据更紧凑。
poll
的缺点
-
仍然需要遍历: 尽管
poll
没有文件描述符数量限制,但在poll
返回后,仍然需要遍历整个fds
数组来查找哪些文件描述符就绪,当文件描述符数量非常庞大时,这会成为一个性能瓶颈。