IO 中的阻塞、非阻塞、同步、异步及五种IO模型
在网络编程的世界里,IO 操作的效率和处理方式是影响系统性能的关键因素。而理解阻塞、非阻塞、同步、异步这四个核心概念,是掌握高性能网络编程的基础。本文将深入剖析这些概念,通过典型的 IO 操作过程,揭示它们的本质区别和应用场景。
目录
一次 IO 的两个典型阶段
一、数据准备阶段
1. 阻塞模式
2. 非阻塞模式
二、数据读写阶段
1. 同步模式
2. 异步模式
三、关键结论
四、与业务中并发的同步异步区分
五、总结:IO 中的阻塞、非阻塞、同步、异步
Linux 上的五种 IO 模型
一、阻塞 IO 模型(Blocking IO)
二、非阻塞 IO 模型(Non-blocking IO)
三、IO 复用模型(IO Multiplexing)
四、信号驱动 IO 模型(Signal-Driven IO)
五、异步 IO 模型(Asynchronous IO)
六、五种 IO 模型对比
七、关键区别总结
一次 IO 的两个典型阶段
在探讨阻塞、非阻塞、同步、异步之前,我们需要先明确一个典型的网络 IO 操作所包含的两个阶段:数据准备(数据就绪)阶段和数据读写阶段。这两个阶段的处理方式不同,导致了不同的 IO 模型。
一、数据准备阶段
数据准备阶段是指系统 IO 操作检测数据是否就绪的过程。根据系统对 IO 操作就绪状态的处理方式,可分为阻塞和非阻塞两种模式。
1. 阻塞模式
在阻塞模式下,当调用如recv
这样的 IO 接口时,如果目标套接字(sockfd)上没有数据到来,当前线程会被阻塞,进入等待状态,直到数据到达。下面是一个典型的阻塞 IO 调用示例:
int size = recv(sockfd, buf, 1024, 0);
在这个例子中,如果sockfd
对应的内核 TCP 接收缓冲区中没有数据,recv
函数会一直等待,不会返回,直到有数据到达或者连接关闭。这种方式的优点是代码逻辑简单,缺点是线程在等待过程中无法执行其他任务,会造成资源浪费,尤其在高并发场景下问题更为突出。
2. 非阻塞模式
非阻塞模式则不同,当设置套接字为非阻塞模式后,如果调用recv
时目标套接字上没有数据,函数会立即返回,而不会阻塞当前线程。此时,需要通过返回值来判断数据是否就绪:
int size = recv(sockfd, buf, 1024, 0);
if (size == -1 && errno == EAGAIN) {// 数据尚未准备好,这是正常的非阻塞返回// 可以继续执行其他任务或再次尝试读取
} else if (size == 0) {// 对端关闭了连接
} else if (size > 0) {// 数据已就绪并成功读取
} else {// 发生其他错误,需要进行错误处理
}
在非阻塞模式下,通常需要在循环中不断检查返回值,直到数据就绪。这种方式虽然避免了线程阻塞,但如果数据长时间未就绪,会导致 CPU 空转,浪费 CPU 资源。因此,非阻塞 IO 通常需要配合多路复用技术(如 select、poll、epoll)一起使用,以提高效率。
二、数据读写阶段
数据读写阶段是指将数据从内核缓冲区传输到应用程序缓冲区,或者从应用程序缓冲区传输到内核缓冲区的过程。根据应用程序与内核的交互方式,可分为同步和异步两种模式。
1. 同步模式
在同步模式下,数据的读写操作由应用程序自己完成。当调用recv
等同步 IO 接口时,如果数据已就绪,函数会将数据从内核的 TCP 缓冲区复制到应用程序提供的缓冲区中(这个过程由应用程序执行)。在这个数据拷贝过程中,代码会阻塞在recv
函数处,直到数据拷贝完成才会返回。例如:
int size = recv(sockfd, buf, 1024, 0);
这里的recv
就是一个典型的同步 IO 接口。即使在非阻塞模式下,只要数据就绪后进行读写操作时,应用程序仍需要等待数据传输完成,因此非阻塞 IO 在数据读写阶段仍然属于同步 IO。
2. 异步模式
异步模式则完全不同。在异步 IO 中,数据的读写操作由内核负责完成,应用程序只需向内核发起 IO 请求,并指定当操作完成时的通知方式,然后就可以继续执行其他业务逻辑。当内核完成数据的读写操作后,会通过事先约定的方式(如信号或回调函数)通知应用程序。例如:
#include <aio.h>// 定义异步IO控制块
struct aiocb aiocb;// 初始化aiocb结构
memset(&aiocb, 0, sizeof(struct aiocb));
aiocb.aio_fildes = sockfd;
aiocb.aio_buf = buf;
aiocb.aio_nbytes = 1024;
aiocb.aio_offset = 0;// 设置回调函数(当IO完成时调用)
aiocb.aio_sigevent.sigev_notify = SIGEV_CALLBACK;
aiocb.aio_sigevent.sigev_notify_function = my_callback_function;
aiocb.aio_sigevent.sigev_notify_attributes = NULL;// 发起异步读操作
int ret = aio_read(&aiocb);
if (ret != 0) {// 处理错误
}// 继续执行其他业务逻辑,无需等待IO完成
在这个例子中,aio_read
函数会立即返回,不会阻塞当前线程。当数据从内核缓冲区复制到应用程序缓冲区完成后,内核会调用my_callback_function
函数通知应用程序。这种方式使得应用程序在 IO 操作进行过程中可以继续执行其他任务,大大提高了并发处理能力。
三、关键结论
需要特别强调的是,在处理 IO 时,阻塞和非阻塞实际上都属于同步 IO 范畴,只有使用像aio_read
、aio_write
这样的特殊 API 才是真正的异步 IO。这是因为,无论是阻塞 IO 还是非阻塞 IO,当数据就绪后进行读写操作时,应用程序都需要等待数据传输完成(即使是非阻塞 IO,也需要通过轮询不断检查状态),而真正的异步 IO 则是由内核完全接管数据传输,应用程序无需等待。
来自muduo作者陈硕:
***在处理IO时,阻塞和非阻塞都是同步IO,只有使用特殊的API才是异步IO***
四、与业务中并发的同步异步区分
在业务开发中,也经常会提到同步和异步的概念,但这与 IO 模型中的同步异步有所不同,需要加以区分:
- 业务中的同步:是指操作 A 需要等待操作 B 完成后才能继续执行后续逻辑。例如,在调用一个远程 API 时,程序会等待 API 返回结果后再继续执行下一步。
- 业务中的异步:是指操作 A 向操作 B 发起请求,并告知 B 自己感兴趣的事件以及事件发生时的通知方式,然后操作 A 就可以继续执行自己的业务逻辑。当操作 B 监听到相应事件发生后,会按照约定的方式通知操作 A,A 再进行相应的数据处理。例如,在消息队列系统中,生产者发送消息后不需要等待消费者处理结果,可以继续执行其他任务,消费者处理完消息后可以通过回调或消息通知生产者。
五、总结:IO 中的阻塞、非阻塞、同步、异步
综上所述,一个典型的网络 IO 接口调用可以分为 “数据就绪(数据准备)” 和 “数据读写” 两个阶段:
- 在数据就绪阶段,根据是否阻塞当前线程,分为阻塞和非阻塞两种模式。
- 在数据读写阶段,根据是由应用程序还是内核负责完成数据传输,分为同步和异步两种模式。
Linux 上的五种 IO 模型
在 Linux 系统中,根据数据准备和数据传输阶段的不同处理方式,可将 IO 模型分为五类。这些模型从简单到复杂,逐步提升系统在高并发场景下的处理能力。
下文图片来源:【Linux高级IO】五种IO模型_【linux】五种io模型之高性能io技术详解-CSDN博客
一、阻塞 IO 模型(Blocking IO)
阻塞 IO 是最基本的 IO 模型,其核心特点是在数据准备和数据传输阶段均会阻塞进程。以网络套接字为例:
// 创建套接字并连接服务器
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));// 调用 recv 接收数据(默认阻塞模式)
char buffer[1024];
int n = recv(sockfd, buffer, 1024, 0); // 进程在此处阻塞// 数据就绪并复制完成后继续执行
process_data(buffer, n);
工作流程:
- 进程调用
recv
进入内核态 - 若数据未就绪(TCP 缓冲区为空),进程被挂起(进入睡眠状态)
- 数据到达后,内核将数据从网卡复制到内核缓冲区
- 内核将数据复制到用户空间缓冲区
recv
返回,进程恢复执行
特点:
- 实现简单,代码逻辑清晰
- 但同一时间每个进程只能处理一个 IO 请求
- 在高并发场景下需要大量进程 / 线程,资源消耗大
二、非阻塞 IO 模型(Non-blocking IO)
非阻塞 IO 通过设置套接字为非阻塞模式,避免在数据准备阶段阻塞进程:
// 设置套接字为非阻塞模式
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);// 循环尝试读取数据
while (1) {int n = recv(sockfd, buffer, 1024, 0);if (n == -1 && errno == EAGAIN) {// 数据未就绪,继续处理其他任务handle_other_tasks();} else if (n > 0) {// 数据就绪,处理数据process_data(buffer, n);break;} else {// 处理错误handle_error();break;}
}
工作流程:
- 进程调用
recv
立即返回(无论数据是否就绪) - 若数据未就绪,返回
EAGAIN
(等同于EWOULDBLOCK)错误 - 进程可继续执行其他任务,定期轮询检查数据状态
- 数据就绪后,再次调用
recv
完成数据复制
特点:
- 避免进程阻塞,可在等待期间处理其他任务
- 但频繁轮询会消耗大量 CPU 资源
- 适用于 IO 就绪时间短的场景
三、IO 复用模型(IO Multiplexing)
IO 复用模型通过单个进程同时监视多个文件描述符(FD),提高并发处理能力。常见的实现有 select
、poll
和 epoll
:
// 使用 select 实现 IO 复用
fd_set readfds;
struct timeval timeout;// 初始化文件描述符集合
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
FD_SET(other_fd, &readfds);// 设置超时时间
timeout.tv_sec = 5;
timeout.tv_usec = 0;// 调用 select 监视多个 FD
int activity = select(max_fd + 1, &readfds, NULL, NULL, &timeout);if (activity > 0) {// 检查哪些 FD 就绪if (FD_ISSET(sockfd, &readfds)) {// 处理套接字数据recv(sockfd, buffer, 1024, 0);}if (FD_ISSET(other_fd, &readfds)) {// 处理其他 FD}
}
工作流程:
- 进程调用
select/poll/epoll_wait
进入阻塞状态 - 内核监视所有注册的 FD,任一 FD 就绪时唤醒进程
- 进程遍历 FD 集合,找出就绪的 FD 进行处理
- 对就绪的 FD 调用
recv
完成数据复制
特点:
- 单个进程可同时处理多个 IO 请求
- 相比多进程 / 线程模型,资源消耗显著降低
epoll
在大规模 FD 场景下性能更优(时间复杂度 O (1))
四、信号驱动 IO 模型(Signal-Driven IO)
信号驱动 IO 使用异步通知机制,当数据就绪时通过信号通知进程:
// 安装信号处理函数
void sigio_handler(int signo) {// 处理数据就绪事件recv(sockfd, buffer, 1024, 0);
}// 设置信号处理
signal(SIGIO, sigio_handler);// 设置套接字为异步模式并绑定进程
fcntl(sockfd, F_SETOWN, getpid());
int flags = fcntl(sockfd, F_GETFL);
fcntl(sockfd, F_SETFL, flags | FASYNC);// 进程继续执行其他任务
while (1) {// 处理核心业务逻辑process_main_logic();// 无需主动检查 IO 状态
}
工作流程:
- 进程通过
fcntl
设置套接字为异步模式并注册信号处理函数 - 内核在数据就绪时发送
SIGIO
信号给进程 - 进程在信号处理函数中调用
recv
完成数据复制
特点:
- 数据准备阶段非阻塞,进程可继续执行主逻辑
- 相比轮询方式,减少了 CPU 消耗
- 但信号处理函数可能干扰主程序执行流程
在第一阶段是异步的,在第二阶段是同步的;与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断轮询检查,减少了系统API调用次数,提高效率。
五、异步 IO 模型(Asynchronous IO)
真正的异步 IO 模型中,进程只需发起 IO 请求,内核完成整个数据传输过程后通知进程:
#include <aio.h>// 定义异步 IO 控制块
struct aiocb aiocb;// 初始化控制块
memset(&aiocb, 0, sizeof(aiocb));
aiocb.aio_fildes = sockfd;
aiocb.aio_buf = buffer;
aiocb.aio_nbytes = 1024;
aiocb.aio_offset = 0;// 设置完成回调
aiocb.aio_sigevent.sigev_notify = SIGEV_THREAD;
aiocb.aio_sigevent.sigev_notify_function = io_complete_handler;
aiocb.aio_sigevent.sigev_notify_attributes = NULL;// 发起异步读操作
aio_read(&aiocb);// 进程继续执行其他任务,无需等待
process_other_work();
工作流程:
- 进程调用
aio_read
发起异步请求,立即返回 - 内核在后台完成数据准备和数据复制操作
- 数据完全传输到用户空间后,通过回调函数通知进程
特点:
- 整个 IO 过程(包括数据准备和传输)均非阻塞
- 进程无需主动干预 IO 操作,效率最高
- 需操作系统和应用程序共同支持(如 Linux 的
aio
系列函数)
六、五种 IO 模型对比
IO 模型 | 数据准备阶段 | 数据传输阶段 | 进程状态 | 典型应用场景 |
---|---|---|---|---|
阻塞 IO | 阻塞 | 阻塞 | 挂起等待 | 简单单线程应用 |
非阻塞 IO | 非阻塞 | 阻塞 | 轮询检查 | 实时性要求不高的小并发场景 |
IO 复用 | 阻塞 | 阻塞 | 单进程监视多 FD | 高并发网络服务器 |
信号驱动 IO | 非阻塞 | 阻塞 | 信号回调 | 实时性要求较高的场景 |
异步 IO | 非阻塞 | 非阻塞 | 完全无感知 | 高性能数据库、流媒体服务器 |
七、关键区别总结
-
同步 vs 异步:
- 同步 IO(阻塞、非阻塞、IO 复用、信号驱动):进程需要主动参与数据传输过程
- 异步 IO:内核完全负责数据传输,完成后通知进程
-
阻塞 vs 非阻塞:
- 阻塞:进程在数据准备或传输阶段被挂起
- 非阻塞:进程可继续执行其他任务,通过轮询或回调处理 IO