前言
推荐几篇对本文的创作提供了帮助的文章:3.一文读懂网络 IO 模型_网络io-优快云博客
一、io多路复用是什么?
1.定义
I/O 多路复用是一种高效的 I/O 模型,用于同时处理多个文件描述符(如套接字、管道、文件等)的 I/O 事件。它通过监视这些文件描述符,确定哪些文件描述符已经准备好进行 I/O 操作(如读取或写入),从而避免阻塞在单个文件描述符的 I/O 操作上。
I/O 多路复用在网络编程中非常重要,尤其是在高并发服务器中,因为它允许单个线程或进程高效地处理多个客户端连接,而无需为每个连接都创建一个线程或进程。
2.另一种理解
某教室中有10名学生和1位教师,这些孩子并非等闲之辈,上课时不停地提问。 学校没办法,只能给每个学生都配1位教师,也就是说教室中现有10位教师。此后,只要有新的转校生,就会增加1位教师,因为转校生也喜欢提问。这个故事中,如果把学生当作客户端,把教师当作与客户端进行数据交换的服务器端进程,则该教室的运营方式为多线程服务器端方式。
有一天,该校来了位具有超能力的教师。这位教师可以应对所有学生的提问,而且回答速度很快,不会让学生等待。因此,学校为了提高教师效率,将其他老师转移到了别的班。现在,学生提问前必须举手,教师确认举手学生的提问后再回答问题。也就是说,现在的教室以I/O复用方式运行。
虽然例子有些奇怪,但可以通过它理解I/O复用技法:教师必须确认有无举手学生,同样,I/O复用服务器端的线程需要确认举手(收到数据)的套接字,并通过举手的套接字接收数据。
3.I/O 多路复用的核心特点
- 同时监听多个文件描述符:
- 监视多个文件描述符的状态变化,例如是否可以读、写或是否发生异常。
- 避免阻塞:
- 不会在单个文件描述符上阻塞,可以同时等待多个文件描述符的 I/O 准备就绪。
- 事件驱动:
- 操作系统通过通知告诉应用程序哪些文件描述符已准备好,从而减少无效的轮询。
4.常见的 I/O 多路复用机制
1. select
- 特点:最早的 I/O 多路复用接口,跨平台支持好。
- 限制:
- 文件描述符数量有限(通常为 1024 或 2048)。
- 每次调用都需要遍历所有文件描述符,性能较差。
- 需要重置监控的文件描述符集合。
2. poll
- 特点:改进了
select
,没有文件描述符数量的限制。 - 限制:
- 仍需遍历所有文件描述符,性能在高并发下会下降。
3. epoll
(Linux 专用)
- 特点:高性能 I/O 多路复用机制,适用于大规模并发连接。
- 优势:
- 不需要遍历所有文件描述符。
- 支持事件通知模型(边沿触发)。
- 限制:仅支持 Linux。
5.I/O 多路复用的工作流程
以下是 I/O 多路复用的典型工作流程:
- 应用程序将一组文件描述符(如套接字)注册到操作系统,告诉操作系统需要监听哪些事件(如读、写或异常)。
- 操作系统监视这些文件描述符的状态。
- 当某些文件描述符就绪(如有数据可读)时,操作系统会通知应用程序。
- 应用程序根据通知处理具体的 I/O 操作。
6.I/O 多路复用与其他 I/O 模型的对比
模型 | 特点 | 缺点 |
---|---|---|
阻塞 I/O | 每个 I/O 操作会阻塞进程,直到操作完成 | 效率低,浪费 CPU 时间。 |
非阻塞 I/O | I/O 操作立即返回,进程需不断轮询文件描述符的状态 | 轮询会浪费 CPU 时间,增加系统开销。 |
I/O 多路复用 | 同时监听多个文件描述符,通过事件通知机制处理 I/O | select 和 poll 的性能在高并发场景下可能较差;复杂性比简单阻塞 I/O 模型高。 |
信号驱动 I/O | 利用信号处理机制通知应用程序某文件描述符可读或可写 | 信号处理机制较复杂,可靠性不足。 |
异步 I/O (AIO) | 操作系统全权负责 I/O,I/O 操作完成后通知应用程序(真正的异步模型) | 通用性较差,支持度有限(Linux 的实现不够完善)。 |
7.I/O 多路复用的优点
- 高效性:
- 单线程可以管理大量文件描述符,节省了线程/进程切换的开销。
- 灵活性:
- 可以监视多种事件(如可读、可写、异常),适合多种场景。
- 适合高并发场景:
- 在网络服务器开发中,尤其适合处理大量客户端连接。
8.I/O 多路复用的典型场景
- 高并发网络服务器:
- 如 HTTP 服务器或聊天室服务器。
- 实时数据传输:
- 需要同时监听多个数据源的应用程序。
- 文件与网络操作混合处理:
- 同时处理文件 I/O 和网络 I/O。
二、使用 select
实现 I/O 多路复用
1.select的核心思想
将多个文件描述符集中交给操作系统进行监控,操作系统会阻塞程序的执行,直到至少一个文件描述符准备好读、写或出现异常。这种机制避免了程序通过轮询方式反复检查每个文件描述符的状态,节省了 CPU 资源。
2.select函数调用过程
3.设置文件描述符
fd_set
是一个固定大小的位图结构,用来表示文件描述符集合。
在fd_set变量中注册和更改值的操作由下列常用的宏函数完成:
FD_ZERO(fd_set *set)
:清空集合。FD_SET(int fd, fd_set *set)
:将文件描述符fd
添加到集合中。FD_CLR(int fd, fd_set *set)
:将文件描述符fd
从集合中移除。FD_ISSET(int fd, fd_set *set)
:检查文件描述符fd
是否在集合中。
4.设置检查范围及超时
想要完成这个步骤,需要先介绍select函数,select的函数原型如下:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
- nfds:监视的文件描述符集合中最大文件描述符加1。
- readfds:指向fd_set结构的指针,用于监视读就绪的文件描述符。
- writefds:指向fd_set结构的指针,用于监视写就绪的文件描述符。
- exceptfds:指向fd_set结构的指针,用于监视异常的文件描述符。
- timeout:指向timeval结构的指针,用于设置select的超时时间。
如上所述,select函数用来验证3种监视项的变化情况。根据监视项声明3个fd_ set型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在此之前(调用select函数前)需要决定下面2件事。
“文件描述符的监视(检查)范围是?”“如何设定select函数的超时时间?
第一,文件描述符的监视范围与select函数的第一个参数有关。实际上,select函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在fd_ set变量中的文件描述符数。但每次新建文件描述符时,其值都会增1,故只需将最大的文件描述符值加1再传递到select函数即可。加1是因为文件描述符的值从0开始。
第二,select函数的超时时间与select函数的最后一个参 数有关,其中timeval结构体定义如下。
struct timeval
{
long tv _sec; //seconds
long tv_ usec; / /microseconds
}
本来select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过声明上述结构体变量,将秒数填人tv_ sec成员,将毫秒数填入tv_ usec成员,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select函数返回0。因此,可以通过返回值了解返回原因。如果不想设置超时,则传递NULL参数。
5.源码展示
(1)固定部分:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
servaddr.sin_port = htons(2000); // 0-1023,
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
listen(sockfd, 10);
printf("listen finshed: %d\n", sockfd); // 3
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
(2)差异部分:
fd_set rfds, rset;
FD_ZERO(&rfds);
FD_SET(sockfd, &rfds);
int maxfd = sockfd;
while (1) {
rset = rfds;
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
if (FD_ISSET(sockfd, &rset)) { // 只有在 sockfd 可读时才 accept
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed: %d\n", clientfd);
FD_SET(clientfd, &rfds); //
if (clientfd > maxfd) maxfd = clientfd;
}
// recv
int i = 0;
for (i = sockfd+1; i <= maxfd;i ++) { // i fd
if (FD_ISSET(i, &rset)) {
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", i);
close(i);
FD_CLR(i, &rfds);
continue;
}
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
6.代码分析
这段代码实现了一个基于 select
的 TCP 服务器,能够同时处理多个客户端连接。其核心思想是使用 I/O 多路复用 来监听多个套接字,并在有事件发生时进行相应的处理。
固定部分不再赘述,可看上一篇文章。差异部分的分析如下:
(1)定义 fd_set
并初始化
fd_set rfds, rset;
FD_ZERO(&rfds);
FD_SET(sockfd, &rfds);int maxfd = sockfd;
fd_set rfds, rset
:rfds
是主集合,保存所有的监听文件描述符。rset
是临时集合,每次select()
调用时都会被复制。
FD_ZERO(&rfds)
清空rfds
。FD_SET(sockfd, &rfds)
将服务器监听的sockfd
加入rfds
,用于监听新的客户端连接。maxfd
记录当前所有文件描述符的最大值,供select()
使用。
(2)select
监听多路 I/O
while (1) {
rset = rfds; // 复制文件描述符集合
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
select()
等待文件描述符集合中的 I/O 事件:
maxfd+1
是select()
的第一个参数,表示要监听的最大文件描述符值加 1(因为文件描述符从0开始)。&rset
传入一个可读事件的监听集合,select()
会修改它,标记哪些文件描述符有可读数据。NULL, NULL, NULL
表示不关心可写事件、异常事件,并且select()
没有超时时间,会一直阻塞直到有事件发生。注意!在select()
被调用时,它会阻塞,直到某些文件描述符上发生了指定的事件。
一个问题:为什么要用rset,rfds两个集合?
调用select函数后,除发生变化的文件描述符对应位外,剩下的所有位将初始化为0。因此,为了记住初始值,必须经过这种复制过程。这是使用select函数的通用方法。
(3)处理新连接
if (FD_ISSET(sockfd, &rset)) { // 监听到新连接
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);FD_SET(clientfd, &rfds); // 将新客户端加入监听集合
if (clientfd > maxfd) maxfd = clientfd; // 更新 maxfd
}
补充1:FD_SET()
和 FD_ISSET()
的作用
FD_SET(fd, &fd_set)
:将文件描述符fd
加入fd_set
集合,表示你希望监听这个文件描述符的事件(比如可读)。FD_ISSET(fd, &fd_set)
:检查文件描述符fd
是否在fd_set
集合中,表示该文件描述符是否发生了事件(如可读、可写或异常)。
- 如果
sockfd
在rset
中,说明有新的客户端连接请求。 accept()
接收新的连接,并返回新的clientfd
,用于与客户端通信。FD_SET(clientfd, &rfds)
将新客户端套接字加入rfds
,使select()
监听它的数据读取事件。maxfd
更新为clientfd
,保证select()
监听到所有连接。
若进入这一代码块,具体流程如下:
- 在程序开始时,调用
FD_SET(sockfd, &rset)
将sockfd
添加到监听集合。 select()
被调用,程序阻塞,直到sockfd
上有事件发生(例如有新客户端请求连接)。- 如果
sockfd
上有新连接请求,select()
返回并标记sockfd
为可读(FD_ISSET(sockfd, &rset)
为真)。 - 程序进入
if (FD_ISSET(sockfd, &rset))
代码块,调用accept()
接受连接。
补充2:select函数函数只有在监视的文件描述符发生变化时才会返回。如果未发生变化,就会进入阻塞状态。如果select返回了大于0的整数,说明相应数量的文件描述符发生了变化。
(4)处理客户端数据
for (int i = sockfd+1; i <= maxfd; i++) { // 遍历所有可能的客户端套接字
if (FD_ISSET(i, &rset)) { // 如果该套接字有数据可读
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
遍历 sockfd+1
到 maxfd
之间的所有文件描述符,检查它们是否有可读数据。
(5)处理客户端断开
if (count == 0) { // 连接关闭
printf("client disconnect: %d\n", i);
close(i);
FD_CLR(i, &rfds); // 从监听集合中移除
continue;
}
recv()
返回0
,表示客户端断开连接。- 关闭套接字
close(i)
,并从rfds
中移除FD_CLR(i, &rfds)
,避免select()
监听已关闭的套接字。
(6)处理数据收发
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
服务器收到数据后,打印出来,并原封不动地返回给客户端(回显服务器)。
7.代码优缺点
代码优点
✅ 支持多个客户端同时连接
- 通过
select()
,可以同时监听多个客户端连接,而不是为每个连接创建线程。
✅ 高效
- 服务器采用单线程
select()
处理 I/O 事件,避免了多线程切换的开销。
✅ 适用于高并发
- 适合中等规模的并发网络服务器,如聊天室、在线客服等应用场景。
代码缺陷
❌ select()
存在性能问题
select()
每次调用都需要遍历所有文件描述符,导致 O(n) 复杂度,在高并发情况下效率较低。FD_SET()
和FD_CLR()
操作会带来额外的 CPU 开销。
❌ 文件描述符上限
select()
受限于FD_SETSIZE
(通常为 1024),意味着无法同时监听超过 1024 个连接。
三、使用 poll实现 I/O 多路复用
1.poll是什么?
poll()也
是一种 I/O 多路复用机制,poll的机制与select类似,与select在本质上没有多大差别,使用方法也类似。相比select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制。poll的参数更少,更直观。
2.poll()
函数
#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 -> 传出 */
};
struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 函数参数:
- fds: 这是一个struct pollfd类型的数组, 里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
- fd:委托内核检测的文件描述符
- events:委托内核检测的fd事件(输入、输出、错误),每一个事件有多个取值
- revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果
- fds: 这是一个struct pollfd类型的数组, 里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
-
- nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数1数组的元素总个数)
- timeout: 指定poll函数的阻塞时长
- -1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞
- 0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回
- 大于0:阻塞指定的毫秒(ms)数之后,解除阻塞
- 函数返回值:
- 失败: 返回-1
- 成功:返回一个大于0的整数,表示检测的集合中已就绪的文件描述符的总个数
3.源码展示
固定部分没有变化,可变部分如下:
struct pollfd fds[1024] = {0}; // 定义一个pollfd结构体数组,用于存储文件描述符和事件
fds[sockfd].fd = sockfd; // 设置sockfd对应的文件描述符
fds[sockfd].events = POLLIN; // 设置事件为可读事件
int maxfd = sockfd; // 初始化最大文件描述符为sockfd
while (1) {
int nready = poll(fds, maxfd+1, -1); // 阻塞等待文件描述符就绪
if (fds[sockfd].revents & POLLIN) { // 如果sockfd有可读事件,表示有新的连接请求
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len); // 接受新连接
printf("accept finished: %d\n", clientfd);
// 将新客户端加入pollfd监控
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN; // 设置客户端为可读事件
if (clientfd > maxfd) maxfd = clientfd; // 更新最大文件描述符
}
int i = 0;
for (i = sockfd+1; i <= maxfd; i++) { // 遍历所有的文件描述符
if (fds[i].revents & POLLIN) { // 如果该文件描述符有可读事件
char buffer[1024] = {0}; // 创建接收缓冲区
int count = recv(i, buffer, 1024, 0); // 从客户端接收数据
if (count == 0) { // 如果接收到的数据为0,表示客户端断开连接
printf("client disconnect: %d\n", i);
close(i); // 关闭该连接
fds[i].fd = -1; // 将该文件描述符标记为无效
fds[i].events = 0; // 清除事件
continue;
}
printf("RECV: %s\n", buffer); // 打印接收到的数据
count = send(i, buffer, count, 0); // 回显数据给客户端
printf("SEND: %d\n", count); // 打印发送的数据长度
}
}
}
4.代码分析
(1) 初始化 pollfd
结构数组:
struct pollfd fds[1024] = {0};
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;
struct pollfd fds[1024]
定义了一个包含 1024 个元素的数组,每个元素是一个pollfd
结构体。每个pollfd
结构表示一个需要被监听的文件描述符。fds[sockfd].fd = sockfd;
设置监听套接字的文件描述符为sockfd
。fds[sockfd].events = POLLIN;
设置监听套接字的事件为POLLIN
,即监听sockfd
是否可读(即是否有新的连接请求)。
(2)设置最大文件描述符:
int maxfd = sockfd;
maxfd
是当前需要监听的最大文件描述符。在 poll()
调用时,我们需要指定 fds
数组的大小(实际上传给 poll()
的是最大文件描述符加一),所以需要知道当前最大的文件描述符。
(3)进入主循环,处理客户端连接与数据:
while (1) {
int nready = poll(fds, maxfd+1, -1);
poll(fds, maxfd+1, -1)
调用 poll()
,它会阻塞,直到某个文件描述符就绪(有数据可读)。fds
是包含所有文件描述符的数组,maxfd + 1
是传给 poll()
的文件描述符总数,-1
表示无限期地阻塞,直到某个文件描述符就绪。
(4)处理新连接(监听套接字 sockfd
):
if (fds[sockfd].revents & POLLIN) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd > maxfd) maxfd = clientfd;
}
fds[sockfd].revents & POLLIN
检查监听套接字sockfd
是否有新的连接请求。如果有(即sockfd
可读),则表示有新的客户端连接请求。accept(sockfd, ...)
接受新的连接并返回客户端的套接字clientfd
。fds[clientfd].fd = clientfd;
将新客户端的文件描述符clientfd
加入到fds
数组中,以便后续对这个客户端的 I/O 进行监听。fds[clientfd].events = POLLIN;
设置新客户端的监听事件为POLLIN
,表示监听该客户端套接字是否有数据可读。if (clientfd > maxfd)
更新maxfd
,确保最大文件描述符始终保持为当前所有套接字中的最大值。
(5)处理已经连接的客户端数据(遍历文件描述符):
int i = 0;
for (i = sockfd+1; i <= maxfd; i++) { // i fd
if (fds[i].revents & POLLIN) {
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", i);
close(i);fds[i].fd = -1;
fds[i].events = 0;
continue;
}printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
- 遍历所有已连接的文件描述符(从
sockfd + 1
到maxfd
),检查每个客户端套接字是否有数据可读(即是否revents
中包含POLLIN
)。 fds[i].revents & POLLIN
检查当前文件描述符i
是否有数据可读。- 如果有数据可读,则调用
recv(i, buffer, 1024, 0)
接收数据,并打印接收到的数据。 - 如果
recv()
返回0
,表示客户端断开连接(正常关闭)。此时,关闭客户端套接字,更新fds[i].fd = -1
,并将其事件设置为0
(即移除该客户端套接字的监听)。 - 否则,使用
send(i, buffer, count, 0)
将接收到的数据发送回客户端,实现回显功能。
(6)总结
这段代码实现了一个基于 poll()
的多路复用服务器,能够同时处理多个客户端连接的输入输出。与 select()
相比,poll()
在处理大数量的文件描述符时更有效,因为 poll()
不限制文件描述符的数量,而是使用一个数组来存储文件描述符信息。此外,poll()
的性能比 select()
更高,因为它不会像 select()
那样每次都需要重设文件描述符集。
总结
这篇文章先介绍了io多路复用的基本概念,然后用select实现了基本的io多路复用。select在性能上虽然优于多线程并发的服务器,但仍有较大缺陷。接下来介绍了poll的实现,poll在select的基础上,做出了一些改进,获得了一定的性能提升;并且poll的参数更少,更直观。但我们一般不会使用poll,因为Linux系统中epoll效率更高。而select可以跨平台,poll只能用于Linux。在下一篇文章,将会介绍epoll这一Linux系统中实现io多路复用,性能最高的方案。