IO多路复用
IO多路复用是什么
理解IO多路复用之前需要先了解几个基本概念:
- IO:IO是
Input/Output
的缩写,表示输入和输出,指计算机系统与外部环境进行数据交换的过程。计算机系统通过IO操作与外部设备(键盘、鼠标、显示器、硬盘、网络等)进行数据的输入和输出。 - 多路:多路是指一个线程可以同时监视和处理多个输入输出事件,无需为每个IO操作创建独立的线程或进程,提高系统的并发性能。
- 复用:复用指的是一个线程或进程同时监听和处理的多个IO事件,这些IO事件共享同一个资源进行IO操作的复用机制。
通过上面概念可以得出一个结论:
IO多路复用是指一种机制,通过这种机制使得
一个线程监听多个IO操作
,而不是每个IO操作都创建一个线程来监听,这些IO事件共享相同资源
,可以大大节省系统资源。
IO读写过程
在计算机系统中,IO读写需要用户态和内核态之间的切换,因为IO操作涉及到硬件资源(硬盘、键盘和终端等设备),这些设备是不能被用户态直接访问的。所以当程序需要进行IO读写或其他类似的硬件资源操作时,不能直接执行,需要通过系统调用的过程转入内核态运行。
- 用户空间:只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
- 内核空间:可以执行特权命令(Ring0),可以调用一切系统资源
IO读写过程
- 写数据:先把用户缓冲数据拷贝到内核缓冲区,然后写入设备
- 读数据:先从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
读数据时需要先等待内核缓冲区数据就绪,然后将内核缓冲区数据拷贝到用户缓冲区
IO多路复用的核心实现
IO多路复用有三个核心组件:缓冲区Buffer、通道Channel、选择器Selector实现,下面一一介绍这些相关概念
通道Channel
通道Channel:代表数据传输的路径,可以是文件、套接字、管道等,通道提供读写操作的接口,使得应用程序可以和输入源、输出目标进行交互,实现数据在缓冲区和输入输出设别之间的传输。
通道的实现类
- FileChannel:提供对文件的读写操作,可以通过FileChannel读取、写入、映射和操作文件的内容
- SocketChannel:提供用于网络Socket通信的通道,SocketChannel封装了底层的套接字,提供对TCP协议的非阻塞式读写操作。
- ServerSocketChannel:提供用于网络Socket通信的通道,ServerSocketChannel类是对ServerChannel类进行了封装,提供对TCP协议的非阻塞式连接监听操作。
套接字:套接字是网络编程中用于实现网络通信的软件
接口
,提供了一种在网络上进行数据传输的机制。套接字可以通过网络中的IP地址和端口号来标识和定位网络中的应用程序。
缓存区Buffer
缓冲区Buffer:缓冲区本质上就是可以暂存数据的内存,数据从输入源读取到缓冲区中,或者从缓冲区中输出到目标中,缓冲区的作用是提供读写操作的临时存储空间,可以减少频繁的IO操作,提高IO操作的性能。
Java提供Buffer的JDK使用 java.nio.Buffer
,根据操作不同数据类型,Buffer的实现类如下:
- IntBuffer
- FloatBuffer
- CharBuffer
- DoubleBuffer
- ShortBuffer
- LongBuffer
- ByteBuffer
从IO设备读取数据的流程:
- 应用程序调用通道的
read()
方法 - 通道往
缓冲区 Buffer
中填入IO设备中的数据,填充完成之后返回 - 应用程序从
缓冲区 Buffer
中获取数据
从IO设备写数据的流程:
- 应用程序往
缓冲区 Buffer
中填入要写到IO设备中的数据 - 调用通道的
write()
方法,通道将数据传输至IO设备
选择器Selector
选择器Selector:选择器用于管理多个通道的IO事件,选择器可以同时监听多个通道上的事件,并在事件就绪时进行相应的处理。多个通道会注册到一个选择器中,通过选择器的轮询或者事件驱动方式,可以对多个通道的IO事件进行监控,提高系统的并发性能。
IO多路复用的函数
IO多路复用使得一个线程可以同时监听多个IO操作,进程如何知道哪些IO操作数据就绪?通过一种机制可以监视多个描述符,一旦某个描述数据准备就绪,就会通知程序进行相应的读写操作,可以通过以下函数实现该机制:
- select
- poll
- epoll
select函数
select函数是最常用的IO多路复用实现之一
select函数原型
java
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明
- nfds:设置需要监听的文件描述符的最大值加1
- readfds:设置需要监听读事件的文件描述符集合
- writefds:设置需要监听写事件的文件描述符集合
- exceptfds:设置需要监听异常事件的文件描述符集合
- timeout:设置select函数的超时事件,如果为NUll,则表示一直阻塞等待
返回值说明
- 成功时返回就绪文件描述符个数
- 超时时返回0
- 出错时返回负值
select函数的使用步骤
- 初始化文件描述符集合,将需要监听的文件描述符添加到对应的集合中
- 调用select函数,传入文件描述符集合和超时时间
- 检查返回值,如果返回值大于0,表明有文件描述符就绪,可以对其进行读写操作;如果返回值为0,表示超时;如果返回值为负值,表示出错
- 重复步骤1-3,直到所有文件描述符都处理完毕
select函数执行流程
- 用户态程序调用select函数,传入需要监听的文件描述符集合、超时时间等信息
- select函数将文件描述符集合从用户态拷贝到内核态,并设置一个等待队列
- select函数进入内核态,阻塞等待文件描述符状态发生变化或者超时事件发生
- 当某个文件描述符就绪时,内核会唤醒select函数
- select函数返回就绪的文件描述符个数
- 用户态程序根据返回值,遍历文件描述符集合,对就绪的文件描述符进行相应的读写操作
- 读写完成后,用户态程序再次调用select函数,继续监听文件描述符状态发生变化
select函数使用举例
c
#include <stdio.h> // 引入标准输入输出头文件
#include <sys/select.h> // 引入select函数所需的头文件
#include <unistd.h> // 引入unistd.h头文件,用于close函数
int main() {
fd_set readfds; // 定义一个文件描述符集合变量readfds
struct timeval timeout; // 定义一个时间结构体变量timeout
int ret; // 定义一个整型变量ret,用于存储select函数的返回值
// 创建一个套接字文件描述符
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) { // 如果创建失败,打印错误信息并返回1
perror("socket");
return 1;
}
// 设置超时时间
timeout.tv_sec = 5; // 设置超时时间为5秒
timeout.tv_usec = 0; // 设置超时时间为0毫秒
// 将套接字文件描述符添加到readfds集合中
FD_SET(sockfd, &readfds);
// 调用select函数,等待文件描述符状态变化或超时
ret = select(sockfd + 1, &readfds, NULL, NULL, &timeout);
if (ret == -1) { // 如果select函数调用失败,打印错误信息并返回1
perror("select");
close(sockfd); // 关闭套接字文件描述符
return 1;
} else if (ret == 0) { // 如果select函数超时,打印提示信息
printf("Timeout!\n");
} else { // 如果select函数返回大于0的值,表示有文件描述符就绪
// 在这里处理就绪的文件描述符,例如读取数据等
printf("File descriptor is ready!\n");
}
// 关闭套接字文件描述符
close(sockfd);
return 0; // 程序正常结束
}
select函数优缺点
select函数优点
- 支持多路IO操作,能同时监听多个文件描述符的状态变化
- 具有良好的移植性,跨平台使用无障碍
- 对于简单的网络编程场景,如单进程单线程处理少量连接时,select比epoll更加简单易用
- 在连接数量较少的情况下,select的性能会更好
select函数缺点
- select能够监听的文件描述符有最大数量上限,这个上限默认等于1024
- 每次调用select函数都需要把文件描述符集合从用户态拷贝到内核态进行监听,如果文件描述符数量较多,这个开销会较大
- select在内核中使用轮询遍历进行监听,当文件描述符较多时,其监听性能会较低
poll函数
poll函数实现机制和select函数非常类似,poll函数优化了select最大文件描述符数量的限制
poll函数原型
c
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll函数参数说明
- fds:结构体数组的指针,每个结构体包含文件描述符、请求的事件类型以及事件处理方式等信息
- nfds:需要监听的文件描述符的数量
- timeout:超时事件,如果在这段时间内没有事件发生,poll就会返回
poll函数返回值说明
- 成功时返回就绪文件描述符个数
- 超时时返回0
- 出错时返回负值
poll函数执行流程
- 用户态程序调用poll函数,传入需要监听的文件描述符集合、文件描述符数量以及超时时间等信息
- poll函数将文件描述符集合从用户态拷贝到内核态,并设置一个等待队列
- poll函数进入内核态,阻塞等待文件描述状态变化或超时时间发生
- 当某个文件描述符就绪时,内核会唤醒poll函数
- poll函数返回就绪的文件描述符个数
- 用户态程序根据返回值,遍历文件描述符集合,对就绪的文件描述进行相应的读写操作
- 读写完成之后,用户态程序再次调用poll函数,继续监听文件描述符状态变化
poll函数使用举例
c
#include <stdio.h> // 引入标准输入输出头文件
#include <sys/poll.h> // 引入poll函数所需的头文件
#include <unistd.h> // 引入unistd.h头文件,用于close函数
int main() {
fd_set readfds; // 定义一个文件描述符集合变量readfds
struct pollfd fds[2]; // 定义一个pollfd结构体数组fds,用于存储文件描述符和事件类型等信息
int ret; // 定义一个整型变量ret,用于存储poll函数的返回值
// 创建一个套接字文件描述符
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) { // 如果创建失败,打印错误信息并返回1
perror("socket");
return 1;
}
// 设置超时时间
struct timeval timeout;
timeout.tv_sec = 5; // 设置超时时间为5秒
timeout.tv_usec = 0; // 设置超时时间为0毫秒
// 将套接字文件描述符添加到readfds集合中
FD_ZERO(&readfds); // 清空readfds集合
FD_SET(sockfd, &readfds); // 将sockfd添加到readfds集合中
// 调用poll函数,等待文件描述符状态变化或超时
ret = poll(fds, 2, timeout.tv_sec * 1000 + timeout.tv_usec / 1000); // 将timeout转换为毫秒
if (ret == -1) { // 如果调用失败,打印错误信息并关闭套接字文件描述符,然后返回1
perror("poll");
close(sockfd);
return 1;
} else if (ret == 0) { // 如果超时,打印提示信息
printf("Timeout!\n");
} else { // 如果发生事件,处理就绪的文件描述符
// 在这里处理就绪的文件描述符,例如读取数据等
printf("File descriptor is ready!\n");
}
// 关闭套接字文件描述符
close(sockfd);
return 0; // 程序正常结束
}
poll函数优缺点
poll函数优点
- 可自行设置需要监听的文件描述符个数
- 通过参数为1的结构体实现请求和返回,因此不需要保存一个母本
- 提供了对文件描述符事件的边缘触发支持
poll函数缺点
- 仅能在linux系统中使用,跨平台兼容性较差
- 和select函数一样,文件描述符数量较多时,将文件描述符的数组从用户空间复制到内核空间时,其复制开销较大
- 同时处理的文件描述符数量存在一定限制,虽然可以通过修改宏定义来增加同时处理的文件描述符数量,但是会降低处理效率
epoll函数
epoll函数是linux系统中的一种IO多路复用技术,提供了高效的IO事件处理机制。相比于select和poll函数,epoll函数具有更高的性能和更好的可扩展性。
epoll函数原型
c
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll函数介绍
- epoll_create:创建一个epoll实例,返回一个文件描述符。size参数表示监听的文件描述符数量的最大值加1
- epoll_ctl:控制epoll实例,可以添加、修改或删除需要监听的文件描述符。op参数表示操作类型,fd参数表示文件描述符。event参数表示需要监听的事件类型和事件处理方式等信息
- epoll_wait:等待epoll实例中的文件描述符就绪,返回就绪的文件描述符个数。
注意:使用完
epoll
函数后,必须调用 close 关闭 epoll 实例的文件描述符,否则可能会导致资源泄露。调用epoll_wait
函数如果设置为非阻塞模式,则需要检查返回值是否为0或错误码 EAGAIN/EWOULDBLOCK,判断是否有事件发生。
epoll函数执行流程
- 用户态程序调用 epoll_create 创建一个epoll实例,并获取一个文件描述符
- 用户态程序调用 epoll_ctl 将需要监听的文件描述符添加到 epoll 实例中,并设置相应的事件类型和事件处理方式等信息。此外,这些信息会被拷贝到内核空间的红黑树中
- epoll 实例在内核空间维护了一个就绪列表,当某个文件描述符就绪时,内核会将其加入到就绪列表中
- 用户态程序调用 epoll_wait 等待 epoll 实例中的文件描述符就绪,返回就绪的文件描述符个数。此时,进程会被阻塞,直到有事件发生或超时
- 如果发生了事件,内核会将就绪列表中的文件描述符拷贝到用户态空间的 events 数组中,并唤醒等待的进程
- 用户态程序根据返回的就绪文件描述符个数,遍历events数组,对就绪的文件描述符进行相应的读写操作
- 重复步骤2和步骤3.直到所有文件描述符都处理完毕
- 调用 close 关闭 epoll 实例的文件描述符
epoll函数使用举例
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/epoll.h>
int main() {
// 创建套接字
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket");
exit(1);
}
// 绑定地址和端口
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
exit(1);
}
// 监听连接
if (listen(server_fd, 5) == -1) {
perror("listen");
exit(1);
}
// 创建epoll实例
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
exit(1);
}
// 将套接字添加到epoll实例中,并设置事件为可读
struct epoll_event event;
memset(&event, 0, sizeof(event));
event.events = EPOLLIN; // 可读事件
event.data.fd = server_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
perror("epoll_ctl");
exit(1);
}
// 主循环,等待事件发生
while (1) {
struct epoll_event events[10]; // 存储发生的事件
int num_events = epoll_wait(epoll_fd, events, 10, -1); // 等待事件发生,超时时间为-1,表示无限等待
if (num_events == -1) {
perror("epoll_wait");
exit(1);
}
// 处理发生的事件
for (int i = 0; i < num_events; i++) {
if (events[i].data.fd == server_fd) { // 如果是新的客户端连接
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_fd == -1) {
perror("accept");
exit(1);
}
// 将新连接的客户端套接字添加到epoll实例中,并设置事件为可读
event.events = EPOLLIN; // 可读事件
event.data.fd = client_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event) == -1) {
perror("epoll_ctl");
exit(1);
}
} else { // 如果是已连接的客户端发送数据
char buffer[1024];
int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer));
if (bytes_read > 0) {
printf("Received data from client: %s
", buffer);
write(events[i].data.fd, buffer, bytes_read); // 将数据回显给客户端
} else if (bytes_read == 0) {
printf("Client disconnected
");
close(events[i].data.fd); // 关闭客户端套接字
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, NULL); // 从epoll实例中移除客户端套接字
} else {
perror("read");
exit(1);
}
}
}
}
// 关闭套接字和epoll实例
close(server_fd);
close(epoll_fd);
return 0;
}
epoll函数的工作模式
epoll的工作模式主要有LT(水平触发)和ET(边沿触发)两种
- LT模式:只要文件描述符的监听事件状态为真,每次调用 epoll_wait 都会返回这个文件描述符。也就是说只要有数据可读,则会一直通知,直到读取了所有的数据。
- ET模式:只有在文件描述符的状态从非激活态变为激活态,才会触发通知。也就是说有部分数据可读,只会通知一次。
LT和ET的对比
- ET模式仅在文件描述符状态发生变化时触发一次事件通知。LT模式只要文件描述符处于可读或可写状态,就会持续触发事件通知。
- ET模式适合非阻塞的、有效利用CPU的高性能IO模式。LT模式对于阻塞IO或者不需要高性能的应用,可以选择LT模式,不需要循环读写,可以直接进行操作。
总体来说,ET模式适合高性能的网络服务器应用,而LT模式适合一般性的应用场景。需要根据实际情况选择适合的模式来处理事件。
epoll函数的优缺点
epoll函数的优点
- 当检查大量的文件描述符时,epoll的性能扩展性比select和poll高很多
- epoll api支持水平触发和边缘触发两种模式,给编程人员提供更大的灵活性
- epoll使用一组函数完成,并且把用户关心的文件描述符上的事件放在内核里的一个事件表中,无需每次调用都重复传入文件描述符集合事件集
epoll函数的缺点
- epoll在处理大量并发连接时表现出色,但其多线程扩展性上存在一定问题,无法很好的满足需求
- epoll支持的最大链接数是进程最大可打开的文件的数目。对于fd数量较少并且fd IO都非常繁忙的情况,epoll的性能较低
- 在一些简单的网络编程场景中,如单进程单线程处理少量连接时,select可能会比epoll更加简单易用。
select、poll、epoll对比
select | poll | epoll | |
---|---|---|---|
获取就绪fd的方式 | 遍历 | 遍历 | 回调 |
底层数据结构 | bitmap | 链表 | 双向链表 |
获取就绪fd的事件复杂度 | On | On | O1 |
最大文件描述符 | 有限制 | 65535 | 65535 |
最大连接数 | 1024 | 无限制 | 无限制 |
FD数据拷贝方式 | 将fd数据从用户空间拷贝到内核空间 | 将fd数据从用户空间拷贝到内核空间 | 使用内存映射,不需要将fd数据频繁拷贝到内核空间 |