在BIO(同步阻塞IO)和NIO(同步非阻塞IO中),在内核进行将数据拷贝到应用程序的过程中(也就是到数据可用),都会导致线程阻塞。这种模式在处理大量并发请求时效率会很低,因为每个线程可能会长时间等待,浪费系统资源。
而多路复用IO是一种允许单个线程通过观察多个IO通道来同时处理多个链接到机制,只有在有IO事件发生时,线程才会进行处理,特别适用于需要处理大量网络连接到场景,比如高并发的服务器应用。
IO多路复用技术通常来说是通过操作系统提供的特定API来实现的,这些API一般由底层的C或C++代码调用,常见的有 select、poll、epoll(Linux)、kqueue(BSD和macOS)等。
IO多路复用API
select(POSIX)
select是最早出现的多路复用API,定义于POSIX标准中,几乎所有类Unix系统都支持。通过检查多个文件描述符(或套接字)来确定哪些文件描述符已经就绪可以执行I/O操作。
当用户程序想要通过 select() 监控多个文件描述符时,用户态调用 select() 函数,这会触发一次系统调用。系统调用是用户态进入内核态的方式,操作系统通过陷阱指令或中断机制将 CPU 的执行权限切换到内核态。
也就是代码中的ret = select(max + 1, &read_fds, NULL, NULL, NULL); 其中read_fds是位掩码,max + 1用来遍历文件描述符
什么是位掩码?
简单来讲,实质上是一个二进制表示的位数组,用来标识哪些文件描述符需要被监控,每一位都对应一个文件描述符,1标识监控该描述符,0则表示不监控;位掩码最大限制为1024,也就是说,select()最多可以监控0-1023着1024个文件描述符,这也是select的缺点之一
什么是文件描述符?
文件描述符是操作系统中用于表示和管理以打开文件或IO资源的一个抽象概念,是一个非负整数,用来标识进程打开的每一个文件或IO资源,通过文件描述符,应用程序可以与底层操作系统进行交互,执行文件读写、网络通信、设备控制等操作。
了解完基本概念之后,再来详细解读select系统调用
在服务器等待来自多个客户端的请求时,使用 select() 来监控所有客户端的连接套接字,将当前进程的所有文件描述符,一次性的从用户态拷贝到内核态;在内核态中快速的遍历每一个fd,判断是否有数据到达。当某个客户端有数据到达时,也就是客户端数据通过网卡并使用DMA拷贝到内核环形缓冲区的时候,网卡会触发硬件中断,通知操作系统有新数据到达,操作系统内核会相应这个中断,进行中断处理,将数据放入与该客户端链接对应的套接字接收缓冲区,此时,返回已就绪fd的个数(内核需要将文件描述符拷贝回用户态),用户态得知某个套接字有数据可读之后,遍历具体已就绪的fd,通过read()等系统调用从内核态的套接字接收缓冲区中读取数据,进行事件处理。场景举例:用户的浏览器(客户端)向一个网站(服务器)发送 HTTP 请求,要求获取网页内容。网站服务器接收到该请求,处理请求并返回网页内容。客户端数据再次场景中就是用户的浏览器,向服务器发送数据,在有数据到达之后在服务器进行处理进行响应。
select实现多路复用技术相关代码:
int listenfd = socket(PF_INET, SOCK_STREAM, 0); // 创建一个socket服务器端 bind(listenfd, (struct sockaddr*)&address, sizeof(address));
listen(listenfd, 5);
fd_set read_fds;
for(i = 0; i < 5; i++) {
//创建5个客户端链接,同时把每一个链接放到一个fd数组中(文件描述符)
fd[i] = accept(listenfd, (struct sockaddr*)&client, &addr_len); // 等待客户端连接
if(fd[i] > max) {
max = fd[i]; // 获取最大的文件描述符
}
}
while(1) {
FD_ZERO(&read_fds); // 文件描述符集合全部置零
for(i = 0; i < 5; i++) {
FD_SET(fd[i], &read_fds); // 将客户端连接的文件描述符添加到监控集合中
}
//阻塞
//第二个参数:读文件描述符集合 --〉 bitmap长度1024
//第三个参数:写文件描述符集合
//第四个参数:异常事件的文件描述符集合
//第五个参数:超时时间 返回已就绪的fd的个数
ret = select(max + 1, &read_fds, NULL, NULL, NULL); // 监控可读事件
}
for(i = 0; i < 5; i++) {
if(FD_ISSET(fd[i], &read_fds)) { // 检查文件描述符是否就绪
ret = recv(fd[i], buff, sizeof(buff) - 1, 0); // 读取数据
}
}
select也有很多不足之处,因为select是早期出现的,并没有考虑很多,文件描述符有长度为1024的限制,无法处理大量并发连接,每次调用都需要遍历整个文件描述符集合,这种线性扫描在高并发场景下效率较低,尤其是在活跃连接较少时,大量无效的遍历会浪费CPU资源。同时,select只能支持水平触发,也就是说在数据到达之后,如果用户态不进行数据的处理,下一次调用还会继续返回该事件;在每一次调用select时,都需要将文件描述符从用户态复制到内核态,进行事件检查之后再复制回来,带来额外的性能开销。此外,在一每一次调用时,都需要先将文件描述符集合进行初始化,也就是每一位都置为0,使得文件描述符不能够进行复用。显然select在高并发的场景下,select并不适用,接下来了解到的poll和epoll将对这些问题进行处理
poll
poll也就是select的改进版本,解决了select的文件描述符数量限制问题,他和select一样,也是用户态发起的系统调用,用来监控多个文件描述符的IO状态,在高并发的场景中,poll和select相比更具有扩展性,但是他和select也具有一些类似的性能问题,下面将对poll进行详细解释
在poll中保存的并不是简单的文件描述符,而是一个文件描述符数组
struct pollfd {
int fd; // 文件描述符
short events; // 注册的事件
short revents; // 实际发生的事件,由内核填充
};
在用户态发起poll系统调用时,将pollfd[]传递给内核(pollfd[] 数组会从用户态拷贝到内核态),CPU从用户态切换为内核态,内核态在接收到poll的系统调用之后,开始对传入的文件描述符数组进行监控,内核将检查每一个文件描述符的当前状态,查看是否满足请求的事件类型(可读可写),与select类似,poll中内核也需要遍历所有的文件描述符(线性扫描),如果在指定的超时时间内,某个或多个文件描述符上发生了事件,也就是客户端发送请求的数据在通过网卡并经过DMA拷贝到内核环形缓冲区的时候,内核将会对文件描述符数组中revents进行更新(内核需要将 pollfd[] 数组中更新过的 revents 字段拷贝回用户态),如果没有任何事件发生并且已经到达超时时间,poll将会返回0,这时,用户态将会得知可读或可写的文件数量,可以发起read等系统调用,内核态将会拷贝数据到用户态,用户态会数据进行处理,同时,用户态会将revents重新置为0
完整代码如下:
上面说过,select有很多的不足,poll中使用的是文件描述符数组,解决了select中1024个文件描述符的限制问题;在用户态拿到poll的返回值进行遍历文件描述符处理的时候,重新将数组中revents置为0,解决了select中fd不可复用的问题,但还是存在多次对文件描述符数组进行copy以及遍历而引起的性能问题。
select和poll调用返回的都是可操作文件描述符的数量,那会不会没用呢?当然不会,而且非常有用!它可以帮助程序决定是否要进行IO操作,从而可以避免不必要的IO操作,节省资源;通过简单判断,也可以帮助程序高效处理就绪的文件描述符,尤其是在监控大量的文件描述符的时候。Linux2.6诞生的epoll将解决上述问题
epoll
epoll是select和poll的改进版本,具有更高的事件通知机制。epoll与select和poll不同,他在内核态中维护较多的事件信息,属于典型的用空间换时间
首先,在用户态中,用户程序会调用epoll_create,epoll_create会创建一个event poll结构体,结构体中包含有三个结构(rdyList、rbr、wq)其中rdyList表示已就绪的文件描述符列表(双向链表),进行回调时会用到;rbr是一个红黑树,管理用户进程放进来的所有的socket连接,在红黑树中,每一个节点对应的value值都是一个epitem结构体;wq中则是阻塞的进程还有回调函数。也就是说在用户程序调用epoll_create之后,内核态便会维护一个event poll结构体。之后用户态将建立客户端连接,并将连接信息文件描述符记录在fd中,进行事件注册(记录需要关注的事件),之后用户态使用epoll_ctl创建客户端和服务器端的连接,并将其注册到event_poll中也就是红黑树里面的epitem中,用户态在进行while循环时,调用epoll_wait来等待IO事件发生,这时候,wq中会存储被阻塞的进程,内核只需要遍历就绪事件列表(在epoll_wait中,内核会监控rbr中红黑树的文件描述符,判断是否有事件发生,内核态进行阻塞监控,当数据从客户端发送过来,并经过网卡,通过DMA拷贝到内核环形缓冲区的时候,存在一个数据接收列表里,这时候,会根据数据接收列表中的数据,在红黑树中找到对应的文件描述符,就相当于对应的文件描述符接收到数据了,之后会将红黑树中对应的节点信息移动到已就绪事件列表rdyList中(文件描述符仍存在红黑树中);并找到对应的处理进程,这时候,需要将用户的进程进行唤醒,然后将已就绪事件列表中的数据返回给用户态,并将事件关联的文件描述符和事件类型返回给用户态程序,)在用户态拿到处理的数据之后,用户态程序会根据 epoll_wait() 的返回结果,遍历这些就绪的文件描述符,并执行相应的操作。
完整代码如下:
struct epoll_event {
__uint32_t events; // 表示需要监控的事件类型,例如 EPOLLIN 表示可读事件。
epoll_data_t data; // 相关的文件描述符或用户数据,文件描述符。
};
typedef union epoll_data {
void *ptr;
int fd; // 文件描述符
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
struct epoll_event events[5]; // 定义epoll的结构体数组
int epoll_fd = epoll_create(5); // 创建epoll实例,参数表示可以监听的文件描述符数量
for(i = 0; i < 5; i++) {
struct epoll_event event;
event.data.fd = accept(sscfd, (struct sockaddr*)&client, &addrLen); // 接受客户端连接
event.events = EPOLLIN; // 注册可读事件
epoll_ctl(epoll_fd, EPOLL_CTL_ADD,event.data.fd, &event); // 注册到epoll
}
while(1) {
int ret = epoll_wait(epoll_fd, events, 5, 2000); // 等待事件发生
for(i = 0; i < ret; i++) {
if(events[i].events & EPOLLIN) { // 如果发生了可读事件
recv(sockfd, buf, BUFFER_SIZE - 1, 0); // 读取数据
}
}
}
struct epitem {
struct rb_node rbn; // 红黑树节点
struct epoll_filefd ffd; // 文件描述符信息
struct eventpoll *ep; // epoll 实例
struct list_head pwqlist; // 等待队列
};
上面提到select和poll还有很多缺点,在epoll中,采用红黑树和双向链表存储事件,没有最大连接限制;传递文件描述符之需要在调用epoll_ctl时传递一次,在epoll_ctl函数中,为每一个文件描述符都制定了回调函数,基于回调函数把就绪事件放在就绪队列中,降低时间复杂度,在有事件发生时也不需要在次传递文件描述符,只需要遍历已就绪事件列表。
epoll_ctl() 与回调机制
当你调用 epoll_ctl() 向 epoll 注册一个文件描述符时,内核会通过回调机制将该文件描述符的事件挂钩到 epoll 的事件检测机制中。这个过程包括以下几个步骤:
- 注册文件描述符
epoll_ctl() 会将文件描述符(如一个套接字)注册到 epoll 的监控列表中(即红黑树 rbr)。
2. 设置回调机制
内核会为这个文件描述符设置一个事件回调函数,这个回调函数通常是基于文件操作结构(file_operations)中的 poll 函数。这是 epoll 的底层实现,它通过这个回调机制来检测文件描述符上的事件状态。
3. 事件发生时调用回调函数
当文件描述符上发生了 I/O 事件(例如有数据可读或可写),内核会通过该回调机制将该事件通知 epoll,并将该文件描述符标记为就绪,加入到 rdyList(就绪队列)中。