什么是IO复用
一种在单个线程中管理多个输入/输出通道的技术。它允许一个线程同时监听多个输入流(例如网络套接字、文件描述符等),并在有数据可读或可写时进行相应的处理,而不需要为每个通道创建一个独立的线程
使用多线程搭建服务端会造成大量的执行上下文切换开销,所以出现了单线程的IO复用技术


select
介绍



-
设置文件描述符
![1]()
![1]()
![2]()
-
设置检查(监视)范围及超时
maxfd一般是最大的文件描述符加上1
-
调用select函数后查看结果
![1]()
传递给select函数的文件描述符集会发生变化,所以一般是用一个临时的文件描述符集,先复制然后传递给select函数,避免直接覆写原始的文件描述符集
使用
// 收集客户端信息(准备fds[5], maxfd)
for (int i = 0; i < 5; i++) {
...
fds[i] = accept(sockfd, (struct sockaddr*)&client, &addrlen);
if (fds[i] > max) max = fds[i]
}
while (1) {
// 准备临时的文件描述符集,避免覆写原始的fds[5]
FD_ZERO(&rset);
for (int i = 0; i < 5; i++) {
FD_SET(fds[i], &rset);
}
// 调用select
select(max+1, &rset, NULL, NULL, NULL);
// 查看select调用结果,不断的轮询
for (int i = 0; i < 5; i++) {
if (FD_ISSET(fds[i], &rset)) {
read(fds[i], buffer, MAXBUF);
}
}
}
有事件发生时,FD置位,select返回
优缺点
-
优点
- 有一个整体的用户态到内核态的切换,由内核来监听事件,避免了频繁切换的开销
-
缺点
-
监听的文件描述符大小有限制,1024 bitmap
-
fdset不可重用,每次监听都需要准备一个临时的文件描述符集
-
每次调用都需要将fdset由用户态复制到内核态
-
无法知道发生事件的具体文件描述符,需要进行轮询处理 O(n)
-
poll函数
介绍
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
参数
-
fds: pollfd 类型的结构体数组,包含需要被监视的文件描述符及其相关事件信息
struct pollfd { int fd; // 文件描述符 short events; // 要监听的事件 short revents; // 发生的事件 };-
fd: 需要监视的文件描述符,可以是任何类型的文件描述符,如套接字、管道等
-
events: 要监听的事件,多个事件可以通过按位“或”组合
-
revents: 返回的实际事件,用于指示文件描述符上发生了哪些事件。poll 调用返回后,revents 中将包含触发的事件
-
-
nfds: 该值是 fds 数组中元素的数量,即 要监听的文件描述符的数量
-
timeout: 指定 poll 调用 阻塞的时间,单位为毫秒。
-
timeout == 0: poll 会 立即返回,不会阻塞(非阻塞模式)
-
timeout == -1: poll 会 无限期阻塞,直到有文件描述符准备好
-
timeout > 0: 最大阻塞时间(单位为毫秒),在该时间内返回
-
-
-
返回值
-
> 0: 表示文件描述符数组中有 多少个 文件描述符准备好进行 I/O 操作
-
0: 表示 没有 文件描述符在指定的 timeout 时间内准备好
-
-1: 出现 错误,错误原因可以通过 errno 获取
-
-
事件常量
在 events 和 revents 字段中使用以下常量来指定感兴趣的事件或返回的事件:
-
POLLIN: 数据可读
-
POLLOUT: 数据可写
-
POLLERR: 错误发生
-
POLLHUP: 文件描述符被挂起
-
POLLNVAL: 无效的文件描述符
-
使用
struct pollfd {
int fd;
short events;
short revents;
};
// 准备pollfds
for (int i = 0; i < 5; i++) {
pollfds[i].fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN;
}
while (1) {
// 调用poll
poll(pollfds, 5, 50000);
// 轮询处理
for (int i = 0; i < 5; i++) {
if (pollfds[i].revents & POLLIN) {
// 复位revents
pollfds[i].revents = 0;
read(pollfds[i].fd, buffer, MAXBUF);
}
}
}
有事件发生时,POLLFD的revents字段保存事件,poll返回
优缺点
-
优点:解决了select的前两点,大小不限制,pollfds可以重用
- 可以重用的原因是,内核将发生的事件存放在revents字段中,而传入poll的参数并不需要使用该字段,所以只需将其清零即可复用
-
缺点:select最后两点未解决,需要从用户态复制pollfd数组到内核态,需要进行轮询处理
epoll函数
介绍




epoll将发生事件的文件描述符填充到以下结构体中


使用
struct epoll_event events[5];
int epfd = epoll_create(10);
for (int i = 0; i < 5; i++) {
static struct epoll_event ev;
...
ev.data.fd = accept(sockfd, (struct sockadddr*)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while (1) {
int nfds = epoll_wait(epfd, events, 5, 10000);
for (int i = 0; i < nfds; i++) {
read(events[i].data.fd, buffer, MAXBUF);
}
}
有事件发生时,epoll将有事件触发的fd重排到event数组的前面,epoll_wait返回值为发生事件的文件描述符个数
优缺点
-
优点: 解决了select的缺点
-
无大小限制
-
epoll_event数组可重用(根据epoll函数的返回值每次只需使用数组的前n个即可,内核会修改前n个元素的数据以确保他们可使用)
-
用户态和内核态共享epollfd创建的空间
-
会将发生事件的文件描述符重排到数组前端,无需轮询,O(1)
-
-
缺点:
- 平台兼容性差
条件触发和边缘触发
条件触发和边缘触发的区别在于发生事件的时间点
条件触发(Level-triggered)
-
在条件触发模式下,事件会在每次检查时触发,只要文件描述符 仍然处于“就绪”状态
-
它的行为更像是 “持续触发”,即只要输入缓冲区中有数据,它就会一直通知你
边缘触发(Edge-triggered)
-
边缘触发模式下,只有在文件描述符状态 从“非就绪”变为“就绪”时,系统才会触发事件。即,如果缓冲区中有数据,事件会触发一次,但如果数据还没有被处理完,系统不会再次通知你,除非状态发生变化(比如数据被清空或添加新的数据)
-
这种模式要求你在 单次通知中处理完所有的数据(或者至少尽可能多的数据),否则下次状态改变时可能会错过事件通知
补充
边缘触发相较于条件触发,需要开发者更加小心地处理每一次事件通知,以确保在事件触发时能尽量读取和处理完所有的数据。边缘触发适合处理高性能、低延迟的场景,因为它减少了不必要的事件通知和重复检查
总结
-
条件触发:每次检查时,只要文件描述符就绪,就会继续通知。适合简单的处理场景,适合不关心处理效率的应用
-
边缘触发:只在文件描述符的状态发生变化时通知一次,适合高效、非阻塞的 I/O 操作,但要求应用程序能在一次通知中处理所有的数据,避免遗漏。常用于 将接收数据与处理数据分离的场景,因为它允许应用程序主动去处理数据




posted on
浙公网安备 33010602011771号