IO模型大体分为如下五种:
- 阻塞IO模型
- 非阻塞IO模型
- IO多路复用模型
- 信号驱动IO模型
- 异步IO模型
阻塞IO模型
在阻塞IO模式下,当应用程序发起IO操作(比如读取文件或者网络通信),如果数据没有准备好或者无法立即处理,IO操作会阻塞当前线程或进程,直到数据准备好或者超时才返回结果。伪代码如下:
listenfd = socket(); // 创建一个socket
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(true){
connfd = accept(listenfd); //阻塞,建立连接
int n = read(connfd, buffer);// 阻塞读取数据
··· // 处理
close(connfd); // 关闭连接
}
采用阻塞IO模型,如上代码会在read出阻塞,直到客户端发来消息为止。这会导致的问题是,当在read阻塞,那么如果此时有新的客户端加入,那么无法及时响应。
非阻塞IO模型
在非阻塞IO模式下,应用程序发起IO操作后,即使数据没有准备好或无法立即处理,IO操作也会立即返回,不会阻塞当前线程或进程。需要通过轮询或者其他方式不断检查IO是否完成。示例代码如下:
listenfd = socket(); // 创建一个socket
bind(listenfd); // 绑定
listen(listenfd); // 监听
set_non_blocking(listenfd); // 设置为非阻塞模式
while(true){
connfd = accept(listenfd); // 阻塞尝试建立连接
int n = read(connfd, buffer);// 尝试非阻塞读取数据,返回小于0的值,表示该读取无效
if(n > 0){
··· // 处理数据
}
close(connfd); // 关闭连接
}
这样可以源源不断的建立新的连接,知道read有数据读入时,才会转去处理数据,如上的代码并不完美,因为当建立连接没有读取数据,之后就会关闭该连接,只是为了演示非阻塞IO的概念。
另辟蹊径 – thread
使用一种更好的方式,可以解决非阻塞IO既要求连接的建立并能阻塞执行read函数,又要求能源源不断的接受新的连接,最好的方式是使用线程机制,我们可以在每次连接建立之后,去开辟一个新的线程,来处理这个连接的read请求,示例代码如下:
listenfd = socket(); // 创建一个socket
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(true){
connfd = accept(listenfd); // 阻塞尝试建立连接
thread_crate(recvThread, connfd);// 创建一个线程
}
void recvThread(connfd){
int n = read(connfd, buffer);// 阻塞读取数据
··· // 处理数据
close(connfd); // 关闭连接
}
这样既不会影响主线程不断接受连接,又不会影响每个连接的数据接收,不过这种方式也是存在缺点的,例如在存在大量客户端连接的情况下,创建大量的线程,会非常消耗资源,并且当CPU处理不过来也会阻塞一部分线程的执行。
IO多路复用模型
由于IO操作必须通过系统调用,由用户态切换到内核态去执行IO操作,如果频繁的从用户态切换到内核态,势必会造成非常大的切换开销,因此我们考虑引入IO多路复用技术。准确的来说,就是主线程只发起一次系统调用,然后由操作系统提供的IO管理机制去处理这些连接,然后在返回给用户态,这样就避免了大量的切换开销,同时由于只需要一个或多个线程去处理这些连接,也会节约大量内存。
在IO多路复用中,”多路”指的是多个IO通道(例如网络连接、文件描述符等),而”复用”则表示将这些IO通道汇集到一个地方进行管理和监控。(复用的另一种说法是复用系统调用,从原先非阻塞情况下,每个客户端都需要去调用系统调用去询问内核数据是否准备就绪,改变为了只执行一次系统调用)换句话说,IO多路复用是一种技术,允许程序同时监视和处理多个IO操作,而不必为每个IO操作创建一个独立的线程或进程。
也可以一句话解释:单线程(进程)同时管理若干个文件描述符的IO操作的一种方法。
IO多路复用的几种常见实现:
- select
- poll
- epoll
使用select、poll、epoll等机制,通过一个进程或线程同时监听多个IO事件,实现在一个线程中处理多个IO操作。
select
、poll
和 epoll
都是用于实现IO多路复用的系统调用,但它们之间有一些区别。下面是它们的主要区别:
select
下面简要介绍一下 select
的实现原理:
- 文件描述符集合: 在调用
select
函数之前,应用程序需要通过fd_set
数据结构来描述需要监视的文件描述符集合。fd_set
实际上是一个位图,每个文件描述符对应一个位,用于标识该文件描述符是否需要监视。 - 调用
select
函数: 应用程序调用select
函数并传递fd_set
结构,以及监视IO事件的超时时间。 - 内核处理: 当应用程序调用
select
函数时,内核会将fd_set
结构拷贝到内核空间,并进行相应的事件监视。 - 事件监视: 内核会遍历需要监视的文件描述符集合,并检查每个文件描述符的状态,包括是否有数据可读、是否可以写入等。
- 返回结果: 当有IO事件发生或者超时时,内核会修改
fd_set
结构,标记哪些文件描述符发生了事件。然后将修改后的fd_set
结构拷贝回用户空间,供应用程序处理。 - 应用程序处理: 应用程序根据
fd_set
结构中的标记来判断哪些文件描述符发生了事件,然后进行相应的IO操作处理。
需要注意的是,select
函数的效率较低,主要原因是每次调用 select
都需要将整个 fd_set
结构从用户空间拷贝到内核空间,然后再将修改后的结果拷贝回用户空间。这种复制操作在文件描述符数量较大时会影响性能。因此,在高并发、大规模IO操作的场景下,通常会选择使用更高效的 poll
或 epoll
来代替 select
。
select的优缺点:
select
是最古老的IO多路复用机制之一,可跨平台使用。- 它使用一个
fd_set
的数据结构来管理需要监视的文件描述符,最大支持1024个文件描述符。 select
每次调用时都需要将需要监视的文件描述符集合传递给系统调用,然后系统调用会阻塞直到有IO事件发生或者超时。- 由于每次调用都要传递整个文件描述符集合,当文件描述符数量较大时,效率会降低。
select
是通过线性遍历的方式去访问文件描述符集合,速度慢。
select
函数的定义如下。
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
// nfds:监控的文件描述符集里最大文件描述符加1
// readfds:监控有读数据到达文件描述符集合,传入传出参数
// writefds:监控写数据到达文件描述符集合,传入传出参数
// exceptfds:监控异常发生达文件描述符集合, 传入传出参数
// timeout:定时阻塞监控时间,3种情况
// 1.NULL,永远等下去
// 2.设置timeval,等待固定时间
// 3.设置timeval里时间均为0,检查描述字后立即返回,轮询
服务端代码如下写法:
使用一个线程不断接受连接,并将socket文件描述符放入一个list中。
while(1) {
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
}
使用另外一个线程,不断的将list交给select去处理
while(1) {
// 把一堆文件描述符 list 传给 select 函数
// 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
nready = select(list);
...
}
不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。
只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。
while(1) {
nready = select(list);
// 用户层依然要遍历,只不过少了很多无效的系统调用
for(fd <-- fdlist) {
if(fd != -1) {
// 只读已就绪的文件描述符
read(fd, buf);
// 总共只有 nready 个已就绪描述符,不用过多遍历
if(--nready == 0) break;
}
}
}
poll
他与select
最显著的区别就是去掉了只能创建1024个连接的限制。
poll
是对select
的改进,也可跨平台使用。- 它使用一个
pollfd
的数据结构来管理需要监视的文件描述符,没有数量限制。 poll
每次调用时只需要传递一个pollfd
结构的数组,不需要传递整个文件描述符集合。- 与
select
相比,poll
在处理大量文件描述符时效率更高。
int poll(struct pollfd *fds, nfds_tnfds, int timeout);
struct pollfd {
intfd; /*文件描述符*/
shortevents; /*监控的事件*/
shortrevents; /*监控事件中满足条件返回的事件*/
};
epoll
epoll是目前最先进的IO管理机制,他相对于select or poll
来说,他有如下优点:
epoll
是 Linux 特有的IO多路复用机制,不支持跨平台。- 它使用三个系统调用
epoll_create
、epoll_ctl
和epoll_wait
来管理需要监视的文件描述符。 - 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
- 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降。
- 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
epoll
的设计更加高效和灵活,可以处理大量的文件描述符,它的存储不使用线性结构,转而使用红黑树来管理待检测集合的,因此在文件描述符数量增加时性能基本不受影响。epoll
支持水平触发(LT,Level-Triggered)和边缘触发(ET,Edge-Triggered)两种模式,边缘触发模式只在有新数据到来时才触发事件。- 水平触发(Level-Triggered)是一种IO事件触发模式,用于描述IO多路复用中的一种行为方式。在水平触发模式下,当IO通道上有数据可读或者可写时,IO多路复用机制会通知应用程序,无论应用程序是否已经处理过这些数据。换句话说,只要IO通道上的状态发生变化(比如有新数据到达或者缓冲区可写),就会触发IO事件通知应用程序,即使应用程序没有及时处理这些数据。
- 边缘触发模式下,只有当IO通道上的状态发生变化时,才会触发IO事件通知应用程序。也就是说,只有当有新数据到达或者缓冲区由不可写变为可写时,才会触发IO事件通知应用程序,之后如果应用程序没有及时处理这些数据,不会再次触发事件通知,直到下一次IO状态变化。
综上所述,epoll
是性能最好的IO多路复用机制,特别适合处理大量文件描述符的场景。而 select
和 poll
则适用于一些较小规模的IO多路复用需求。示例代码如下:
epollfd = epoll_create(); // 创建epoll对象
listenfd = socket(); // 创建一个socket
bind(listenfd); // 绑定
listen(listenfd); // 监听
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, EPOLLIN); // 将listenfd加入epoll监听列表
while(true){
events = epoll_wait(epollfd, MAX_EVENTS, timeout); // 等待事件发生
for(int i = 0; i < events; ++i){
if(events[i].data.fd == listenfd){ // 有新连接
connfd = accept(listenfd); // 建立连接
epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, EPOLLIN); // 将connfd加入epoll监听列表
} else if(events[i].events & EPOLLIN){ // 有数据可读
int n = read(events[i].data.fd, buffer); // 读取数据
··· // 处理数据
close(events[i].data.fd); // 关闭连接
}
}
}
信号驱动IO模型
信号驱动IO模型使用信号通知来告知应用程序IO操作已完成,应用程序可以继续执行其他任务,不需要轮询等待IO完成。示例代码如下:
listenfd = socket(); // 创建一个socket
bind(listenfd); // 绑定
listen(listenfd); // 监听
signal(SIGIO, signal_handler); // 设置信号处理函数
fcntl(listenfd, F_SETOWN, getpid()); // 设置IO拥有者为当前进程
fcntl(listenfd, F_SETFL, O_ASYNC | O_NONBLOCK); // 设置异步非阻塞IO
while(true){
pause(); // 等待信号
}
在信号驱动IO模型中,应用程序得到的是一个信号,表明现在可以执行IO操作(如读取或写入数据),但实际的IO操作需要应用程序自己去执行。这意味着,应用程序需要在接收到信号后,自己发起读取或写入数据的操作,而自己执行IO操作时,进程会被阻塞。
更通俗的例子是:想象你在一家餐厅点餐。在信号驱动IO模型中,你点餐后不需要坐着等待食物准备好。你可以回到你的座位上继续做其他事情,如聊天或看书。当厨师做好你的食物后,服务员会通知你(就像操作系统发送一个信号),告诉你食物准备好了,你可以去取餐了。在这个过程中,你在被通知前不需要一直等待,但你需要亲自去取餐。
异步IO模型
异步IO模型通过操作系统完成IO操作,并在IO完成后通知应用程序,应用程序不需要等待IO完成,可以继续执行其他任务。示例代码如下:
listenfd = socket(); // 创建一个socket
bind(listenfd); // 绑定
listen(listenfd); // 监听
aiocb read_aiocb;
memset(&read_aiocb, 0, sizeof(struct aiocb));
read_aiocb.aio_fildes = listenfd;
read_aiocb.aio_buf = buffer;
read_aiocb.aio_nbytes = MAX_SIZE;
read_aiocb.aio_offset = 0;
aio_read(&read_aiocb); // 发起异步读取操作
while(aio_error(&read_aiocb) == EINPROGRESS){ // 等待异步IO完成
··· // 执行其他任务
}
int n = aio_return(&read_aiocb); // 获取异步IO结果
以上是对五种IO模型的介绍和示例代码,可以帮助读者更好地理解每种模型的特点和应用场景。
在异步IO模型中,应用程序请求IO操作后,就可以继续执行其他任务,操作系统会自动完成整个IO操作(包括数据的读取或写入)。一旦IO操作完成,应用程序会收到一个通知,此时它可以直接使用IO操作的结果,而不需要自己进行实际的读写操作。
更通俗的例子是:同样是在餐厅点餐,你点完餐之后也可以做其他事情,但当食物准备好后,服务员不仅会通知你,还会直接把食物送到你的座位上。你无需亲自去取餐,整个过程完全由服务人员处理完毕。
IO模型的比较
在五种IO模型中,可以将它们分为同步IO模型和异步IO模型,具体如下:
同步IO模型
- 阻塞IO模型(Blocking IO): 在阻塞IO模型中,当应用程序发起IO操作时,如果数据没有准备好或者无法立即处理,IO操作会阻塞当前线程或进程,直到数据准备好或者超时才返回结果。
- 非阻塞IO模型(Non-blocking IO): 在非阻塞IO模型中,应用程序发起IO操作后,即使数据没有准备好或者无法立即处理,IO操作也会立即返回,不会阻塞当前线程或进程。应用程序需要周期性地检查IO操作的状态,并根据状态来决定下一步的操作。
异步IO模型
- IO多路复用模型(IO Multiplexing): IO多路复用是一种IO模型,允许应用程序同时监视和处理多个IO通道(例如网络连接、文件描述符),而不必为每个IO通道创建一个独立的线程或者进程。常见的IO多路复用系统调用包括
select
、poll
和epoll
。 - 信号驱动IO模型(Signal-driven IO): 信号驱动IO是一种IO模型,通过信号机制来通知应用程序IO操作的完成状态。应用程序在发起IO操作后,可以继续执行其他任务,当IO操作完成时,操作系统会发送一个信号给应用程序,应用程序通过信号处理函数来处理IO完成事件。
- 异步IO模型(Asynchronous IO): 异步IO是一种IO模型,允许应用程序发起IO操作后立即返回,并继续执行其他任务,当IO操作完成时,操作系统会通知应用程序,并将IO操作的结果返回给应用程序。异步IO模型下,应用程序不需要主动等待IO操作完成,可以更高效地处理IO操作。
总体来说,阻塞IO模型和非阻塞IO模型属于同步IO模型,因为它们在进行IO操作时需要主动等待或者轮询IO状态。而IO多路复用模型、信号驱动IO模型和异步IO模型属于异步IO模型,因为它们在进行IO操作时可以继续执行其他任务,不需要主动等待IO操作的完成。