I/O多路复用(I/O Multiplexing)是一种高效的网络编程技术,允许一个线程同时监控多个文件描述符的状态,当某个文件描述符就绪时进行相应处理。这种技术在高并发服务器中广泛使用。本文将介绍I/O多路复用的核心概念及在Linux中的实现方式。
一、I/O多路复用的基本概念
为什么需要I/O多路复用?
在传统的阻塞I/O模型中,一个线程只能处理一个I/O请求,如果我们想要对来自网络的不同的链接同时进行处理,就需要创建线程,或者进程,这导致我们的程序中存在大量线程,或者进程,浪费系统资源。I/O多路复用通过同时监听多个文件描述符的状态(如可读、可写),在任何一个描述符就绪时通知程序进行操作,从而避免了阻塞等待。
核心思路
I/O多路复用的核心思想是利用内核提供的机制(内核通过判断文件是否做出更改,等操作来改变fd_set,进而返回触发事件的文件描述符),集中管理和监听多个文件描述符,并根据事件就绪情况执行特定操作。这样可以大幅减少线程或进程的数量,提高系统资源使用率,
Select
函数介绍
select函数
cpp
int select(int nfds, fd_set *readfds, fd_set *writefds, \
fd_set *exceptfds, struct timeval *timeout);
nfds :指定待检测的文件描述符范围,它的值应该是所有文件描述符中最大值加 1。select
会检查文件描述符从 0
到 nfds-1
的状态。未使用的文件描述符范围无需浪费资源。
**readfds ,writefds,exceptfds:**分别是读,写,异常的文件描述符的位图,我们需要关心哪一个事件,就把对应的文件描述符加入到其位图结构中,传入进函数。
cpp
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};
timeout: select函数的超时事件,select每一次会返回我们设置超时事件的剩余事件,例如,5秒,运行一秒后会返回4秒。同时,我们可以把参数设置为 NULL 阻塞,直到有事件就绪,select才会返回。
select函数返回值
- 返回值为正数:表示有文件描述符就绪,返回的数字为就绪的文件描述符数量。
- 返回值为 0:表示超时,没有任何文件描述符就绪。
- 返回值为负数:表示调用失败,可通过
errno
获取错误信息
上述所有参数,都需要我们进行一次事件处理后,或者说一次主循环后重新设置,我们需要重新设置关心的文件描述符和事件。
cpp
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
简单示例
cpp
#include <iostream>
#include <memory>
#include <sys/select.h>
#include <stdio.h>
#include <thread>
#include <unistd.h> // 为了使用 sleep 函数模拟输入延时
void* example_select(void* args) {
fd_set read_fds;
FD_ZERO(&read_fds); // 初始化描述符集
FD_SET(0, &read_fds); // 添加标准输入(文件描述符 0)
struct timeval timeout = {5, 0}; // 超时时间 5 秒
// 使用 select 来监视标准输入
int ret = select(1, &read_fds, NULL, NULL, &timeout);
if (ret > 0 && FD_ISSET(0, &read_fds)) {
std::cout << timeout.tv_sec << std::endl;
printf("Input is available.\n");
} else if (ret == 0) {
printf("Timeout occurred.\n");
} else {
perror("select error");
}
return NULL;
}
void simulate_input() {
std::cout << "Simulating input..." << std::endl;
std::cin.get();
}
int main() {
// 创建两个线程
std::thread monitor_thread(example_select, nullptr); // 监视输入线程
std::thread input_thread(simulate_input); // 模拟输入线程
monitor_thread.join();
input_thread.join();
return 0;
}
程序使用两个线程,一个线程使用select进行监视,另外一个创建事件就绪,但是对于select的fs_set来说,有设计上的限制,他最大可以同时监视的文件描述符数量为1024,我们可以在select.h头文件里看到
Poll
对于select来说,Poll的改进就是可以同时监视的文件描述符的限制不是程序,而是硬件的限制,也就是说,对于程序来说没有上限。
函数介绍
fds: 这个结构是属于用户维护,也就是说**,我们可以使用数据结构对其大小进行动态管理**
fd
:要监视的文件描述符(例如,套接字、管道等)。events
:指定要监听的事件类型,使用 poll
支持的事件标志。revents
:返回时,内核填充的已就绪事件类型。使用该字段可以检查哪些事件已经发生。
常见的 events
和 revents
标志:
POLLIN
:文件描述符可读取。POLLOUT
:文件描述符可写入。POLLERR
:文件描述符发生错误。POLLHUP
:文件描述符发生挂起(例如,连接关闭)。POLLNVAL
:文件描述符无效。
**nfds:**我们设置的结构体数组的大小。
**timeout:**poll里的timeout不是结构体,就只是int,单位是毫秒,大于0,就是超时事件,等于0,就是非阻塞,-1就是阻塞。
poll函数的返回值:
- 正数 :表示已就绪的文件描述符数量。
revents
字段会被填充,表示已发生的事件。 - 0:表示超时,没有文件描述符就绪。
- -1 :表示错误,
errno
会被设置为具体错误代码。
简单示例
cpp
void* example_poll(void* args) {
struct pollfd fds[1]; // 使用 pollfd 结构数组来监视文件描述符
fds[0].fd = 0; // 监视标准输入(文件描述符 0)
fds[0].events = POLLIN; // 监听可读事件
fds[0].revents = 0; // 初始化 revents,表示返回的事件状态
int timeout = 5000; // 设置超时时间为 5000 毫秒(即 5 秒)
// 使用 poll 来监视文件描述符
int ret = poll(fds, 1, timeout);
if (ret > 0 && (fds[0].revents & POLLIN)) {
printf("Input is available.\n");
} else if (ret == 0) {
printf("Timeout occurred.\n");
} else {
perror("poll error");
}
return NULL;
}
只需要把头文件加上,上述示例就可以用。
同时,与select一样,每一次循环,需要我们重新设置这些值,那也就是说,poll与select都需要去循环设置,循环查找就绪的文件描述符,然后循环查找空余位置进行文件描述符的插入(没有任何优化),这就导致我们的效率会很低,就算poll解决的文件描述符上限的问题,循环的问题并没有解决。