同步、异步、阻塞、非阻塞详解
第一部分:四个概念的本质区别与联系
一、两个维度
| 维度 | 关注的问题 | 概念 |
|---|---|---|
| 消息通知机制 | 结果怎么拿到? | 同步vs 异步 |
| 等待时的状态 | 等待时线程干嘛? | 阻塞 vs 非阻塞 |
二、逐个拆解
1. 同步 (Synchronous)
调用者主动等待/轮询结果。
调用者 --调用--> 被调用者
调用者 <--返回-- 被调用者 // 调用者一直等到结果返回
核心:结果由调用者自己去获取,在结果返回前,控制流不往下走。
2. 异步 (Asynchronous)
调用者发出请求后立即返回,结果通过回调/通知/事件送达。
调用者 --调用--> 被调用者// 立刻返回,不等结果
...调用者做其他事...
被调用者 --通知/回调--> 调用者// 结果好了主动通知
核心:结果由被调用者主动推送给调用者。
3. 阻塞 (Blocking)
线程被挂起,让出 CPU,直到条件满足才被唤醒。
线程: 发起操作 →挂起(sleep) → ... → 被唤醒 → 继续执行
核心:线程不占用 CPU,操作系统层面被挂起。
4. 非阻塞 (Non-blocking)
调用立即返回(可能返回”未就绪”),线程不会被挂起。
线程: 发起操作 → 立即返回(可能是EAGAIN) → 可以做别的事/再次尝试
核心:线程始终保持运行态,不会被内核挂起。
三、组合关系(关键)
这两个维度是正交的,可以自由组合,共4 种:
1) 同步 + 阻塞(最常见)
// 传统 read(),线程卡住直到数据到达
data = read(fd, buf, size); // 线程挂起,等数据来了才返回
你去餐厅柜台点餐,站在柜台前干等,饭做好了递给你。
2) 同步 + 非阻塞(轮询模型)
// 非阻塞 read,没数据就返回 EAGAIN,调用者反复尝试
while ((n = read(fd, buf, size)) == -1 && errno == EAGAIN) {
//忙等或做点别的,然后再试
}
你去柜台点餐,每隔几秒去问一次”好了没”,没好就走开一会。
3) 异步 + 阻塞(较少见,如 select/epoll)
// select 本身是阻塞的,但它监听的I/O 模型是"就绪通知"式的
select(nfds, &readfds, NULL, NULL, NULL); // 阻塞等待事件通知
// 事件到达后再去读
你坐在大厅等叫号(阻塞等通知),叫到你的号再去取餐。
4) 异步 + 非阻塞(真正的异步 I/O)
// Linux AIO / Windows IOCP / Node.js
aio_read(&aiocb); // 立刻返回,内核完成后触发回调
// 线程继续做别的事
你点完餐就走了去逛街(不等待),饭好了服务员打电话通知你。
四、一张图总结
┌──────────────────────────────────────┐
│ 结果如何获取? │
│ 同步(自己取) 异步(对方送) │
┌───────────┼──────────────────┬───────────────────┤
等 │ 阻塞 │ 传统 read() │ select/epoll │
待 │ (挂起) │ 最简单最直觉 │ I/O 多路复用 │
时 ├───────────┼──────────────────┼───────────────────┤
状 │ 非阻塞 │ 轮询 read() │ AIO / IOCP │
态 │ (不挂起) │ CPU 空转浪费 │ 性能最优 │
└───────────┴──────────────────┴───────────────────┘
五、本质区别一句话
| 概念 | 一句话 |
|---|---|
| 同步 vs 异步 | 谁来推动结果的传递——调用者拉取 vs 被调用者推送 |
| 阻塞 vs 非阻塞 | 等待期间线程的状态——挂起让出CPU vs 保持运行 |
第二部分:从数据移动两阶段理解 I/O 模型
I/O 的本质就是数据搬运,以网络读取 (recv) 为例,数据要经历两个阶段:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 网卡/磁盘 │ ──①──> │ 内核缓冲区 │ ──②──> │ 用户缓冲区 │
│ (设备) │ │ (kernel) │ │(user space)│
└──────────┘ └──────────┘ └──────────┘
阶段一:数据到达设备, 阶段二:内核将数据
DMA 拷贝到内核缓冲区 拷贝到用户进程空间
所有 I/O 模型的差异,就在于线程在这两个阶段分别处于什么状态。
一、五种 I/O 模型逐一拆解
1. 同步阻塞 I/O(Blocking I/O)
用户进程 内核
│ │
│── recvfrom() ─>│
│ (系统调用) │
│ │ 阶段一:数据还没到网卡
│ 【进程挂起】 │ ......等待设备数据......
│ 【进程挂起】 │ ......DMA 拷贝到内核缓冲区......
│ │
│ │ 阶段二:内核→用户空间拷贝
│ 【进程仍挂起】 │ ......copy_to_user()......
│ │
│<── 返回数据 ────│
│ 【进程唤醒】 │
▼ ▼
两个阶段都阻塞:
- 阶段一:数据没到内核,进程 sleep,让出 CPU
- 阶段二:内核拷贝数据到用户空间,进程继续 sleep
- 直到拷贝完成才唤醒进程
最简单,但线程被完全卡住,什么也干不了。
2. 同步非阻塞 I/O(Non-blocking I/O)
用户进程 内核
│ │
│── recvfrom() ────> │ 数据没到
│<── EAGAIN ─────────│ 立即返回
│ │
│── recvfrom() ────> │ 数据没到
│<── EAGAIN ─────────│ 立即返回
│ │
│ (反复轮询...) │
│ │
│── recvfrom() ────> │ 阶段一完成!数据已在内核缓冲区
│ │
│ 【进程阻塞】 │ 阶段二:内核→用户空间拷贝
│ │ ......copy_to_user()......
│<── 返回数据 ───────│
│ 【进程唤醒】 │
▼ ▼
阶段一不阻塞,阶段二仍阻塞:
- 阶段一:内核没有数据就立即返回
EAGAIN,进程不挂起,但需要反复轮询 - 阶段二:一旦数据到达内核,
copy_to_user()期间进程仍被阻塞
阶段一解放了线程,但轮询浪费 CPU;阶段二本质没变。
3. I/O 多路复用(select / poll / epoll)
用户进程 内核
│ │
│── select(fds) ───────> │
│ │ 阶段一:等待任意一个fd数据就绪
│ 【进程阻塞在select】 │ ......监控多个fd......
│ │ fd3 数据到达内核缓冲区!
│<── select 返回就绪fd ──│
│ │
│── recvfrom(fd3) ─────> │
│ 【进程阻塞】 │ 阶段二:内核→用户空间拷贝
│ │ ......copy_to_user()......
│<── 返回数据 ───────────│
▼ ▼
两个阶段都阻塞,但阶段一的阻塞方式不同:
- 阶段一:进程阻塞在
select/epoll上,而非阻塞在具体的 I/O 操作上。好处是一个线程可以同时等待成千上万个 fd - 阶段二:数据就绪后调用
recvfrom,copy_to_user()期间仍阻塞
本质仍是同步的(数据拷贝时进程阻塞),但阶段一实现了”一对多”监听。
4. 信号驱动 I/O(Signal-driven I/O)
用户进程 内核
│ │
│── sigaction(SIGIO) ─────> │ 注册信号处理函数
│<── 立即返回 ──────────────│
│ │
│ 【进程不阻塞,做其他事】 │ 阶段一:等待数据到达
│ │ ......设备→内核缓冲区......
│ │
│<── SIGIO 信号 ────────────│ 数据到达内核!发信号通知
│ │
│── recvfrom() ───────────> │
│ 【进程阻塞】 │ 阶段二:内核→用户空间拷贝
│ │ ......copy_to_user()......
│<── 返回数据 ──────────────│
▼ ▼
阶段一不阻塞且不轮询,阶段二仍阻塞:
- 阶段一:注册信号后立即返回,内核数据就绪后主动发信号通知——这是异步通知
- 阶段二:收到信号后调用
recvfrom,copy_to_user()期间依然阻塞
阶段一是异步的(内核主动通知),但阶段二仍是同步的,所以整体仍归为同步 I/O。
5. 异步 I/O(AIO / IOCP)—— 真正的异步
用户进程 内核
│ │
│── aio_read(buf, callback) ─> │ 告诉内核:数据准备好直接放到buf
│<── 立即返回 ─────────────────│
│ │
│ 【进程完全不阻塞】 │ 阶段一:等待数据到达
│ 【继续做任何其他事】 │ ......设备→内核缓冲区......
│ │
│ 【进程仍不阻塞】 │ 阶段二:内核→用户空间拷贝
│ │ ......copy_to_user() 由内核完成......
│ │
│<── 信号/回调通知 ────────────│ 两个阶段全部完成!
│ 数据已经在 buf 里了 │
▼ ▼
两个阶段都不阻塞:
- 阶段一:发起调用后立即返回,进程不等待
- 阶段二:内核自己完成拷贝到用户空间,进程完全不参与
- 全部完成后通过回调/信号通知进程,此时数据已经在用户缓冲区里了
唯一真正的异步——进程在两个阶段都不需要等待。
二、对比总结表
| I/O 模型 | 阶段一(设备→内核) | 阶段二(内核→用户) | 本质 |
|---|---|---|---|
| 同步阻塞 | 阻塞等待 | 阻塞等待 | 全程卡死 |
| 同步非阻塞 | 轮询(不阻塞) | 阻塞等待 | 阶段一轮询浪费CPU |
| I/O 多路复用 | 阻塞在select上(可监控多fd) | 阻塞等待 | 阶段一一对多 |
| 信号驱动 | 不阻塞(内核信号通知) | 阻塞等待 | 阶段一异步通知 |
| 异步 I/O | 不阻塞 | 不阻塞(内核完成) | 全程无感知 |
三、关键洞察
同步与异步的分界线在阶段二
阶段二:内核 → 用户空间拷贝
│
┌───────────┼───────────┐
│ │
进程自己参与等待 内核独立完成
(recvfrom 阻塞) (拷贝完回调通知)
│ │
同步 I/O 异步 I/O
(前四种都是) (只有 AIO)
POSIX 对同步/异步 I/O 的定义:
- 同步 I/O:导致进程在 I/O 操作期间被阻塞(阶段二
copy_to_user时进程阻塞) - 异步 I/O:不导致进程在 I/O 操作期间被阻塞(阶段二由内核全权完成)
所以前四种模型,无论阶段一多么”异步”,只要阶段二进程仍被阻塞,都属于同步 I/O。只有 AIO 在两个阶段都不需要进程参与,才是真正的异步。
第三部分:阻塞的本质 —— 进程挂起与 CPU 使用权
一、阻塞的本质:进程被挂起,让出 CPU
当进程发生阻塞时:
进程状态转换:
Running (R) ──阻塞事件──> Sleeping (S/D) ──事件就绪──> Running (R)
运行态 睡眠态 运行态
【占用CPU】 【不占用CPU】 【重新占用CPU】
关键点:
- 阻塞 = 进程进入睡眠状态(S 或 D 状态)
- 睡眠状态的进程不会被调度器分配 CPU 时间片
- CPU 被释放出来,去执行其他就绪进程
二、Linux 进程状态详解
| 状态 | 含义 | CPU 使用 |
|---|---|---|
| R | Running/Runnable(运行/就绪) | 正在使用或等待分配 CPU |
| S | Interruptible Sleep(可中断睡眠) | 不占用 CPU |
| D | Uninterruptible Sleep(不可中断睡眠) | 不占用 CPU |
| Z | Zombie(僵尸) | 不占用 CPU |
| T | Stopped(停止) | 不占用 CPU |
阻塞 I/O 会让进程进入 S 或 D 状态,此时进程完全不占用 CPU。
三、阻塞 vs 忙等待对比
┌─────────────────┬──────────────┬──────────────┐
│ │ 阻塞 I/O │ 忙等待 │
├─────────────────┼──────────────┼──────────────┤
│ 进程状态 │ S/D (睡眠) │ R (运行) │
│ CPU 占用 │ 0% │ 接近 100% │
│ 能否被调度 │ 不能 │ 能 │
│ 其他进程受影响 │ 不受影响 │ 抢占 CPU │
│ 唤醒机制 │ 内核中断 │ 自己检测 │
└─────────────────┴──────────────┴──────────────┘
第四部分:copy_to_user() 时进程的真实状态
一、关键纠正
错误理解 ❌
“内核拷贝数据时,进程阻塞(睡眠状态),没有 CPU 使用权”
正确理解 ✅
“内核拷贝数据时,进程处于内核态运行状态,占用 CPU 执行拷贝操作”
二、完整的数据拷贝过程
以 read() 系统调用为例:
用户进程 内核
│ │
│ 用户态 (User Mode) │
│ 进程状态: R (运行) │
│ │
├─ read(fd, buf, size) ────┤ ① 系统调用,陷入内核
│ │
│ ╔════════════════════╗ │
│ ║ 切换到内核态 ║ │
│ ║ (Kernel Mode) ║ │
│ ╚════════════════════╝ │
│ │
│ ├─ ② 检查内核缓冲区有无数据
│ │
│ 【情况A:数据未就绪】 │
│ 进程状态: S (睡眠) ├─ ③ 数据未就绪,进程睡眠
│ CPU使用: 0% │ schedule() 让出CPU
│ ......等待...... │ ......时间流逝......
│ │
│ ├─ ④ 数据到达,中断唤醒进程
│ 进程状态: R (就绪) │ wake_up()
│ │
│ 【情况B:数据已就绪】 │
│ 进程状态: R (运行) │
│ CPU使用: 100% ├─ ⑤ copy_to_user() 执行拷贝
│ (在内核态运行) │ 【进程占用CPU执行拷贝】
│ │ memcpy(user_buf, kernel_buf, size)
│ │
│ ╔════════════════════╗ │
│ ║ 返回用户态 ║ │
│ ║ (User Mode) ║ │
│ ╚════════════════════╝ │
│ │
│ 进程状态: R (运行) │
│ 继续执行后续代码 │
▼ ▼
三、两个阶段的进程状态对比
| 阶段 | 进程状态 | CPU占用 | 说明 |
|---|---|---|---|
| 阶段一:等待数据到达内核 | S (睡眠) | 0% | 进程挂起,让出CPU |
| 阶段二:内核→用户空间拷贝 | R (运行,内核态) | 100% | 进程占用CPU执行拷贝 |
四、为什么拷贝时进程必须占用 CPU?
1. copy_to_user() 是 CPU 指令序列
// 内核源码简化版
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n)
{
// 这是一系列 CPU 指令:
// - 检查地址合法性
// - 逐字节/逐字拷贝(或使用 SIMD 指令)
// - 处理页错误(page fault)
while (n--) {
*to++ = *from++; // CPU 执行的内存拷贝指令
}
return 0;
}
这些指令需要 CPU 来执行,所以进程必须占用 CPU。
2. 进程在内核态运行
进程的两种运行模式:
┌─────────────────────────────────────┐
│ 用户态 (User Mode) │
│ - 执行用户代码 │
│ - 受限的指令集 │
│ - 只能访问用户空间内存 │
├─────────────────────────────────────┤
│ 内核态 (Kernel Mode) │
│ - 执行内核代码 │ ← copy_to_user() 在这里
│ - 完整的指令集 │
│ - 可以访问所有内存 │
└─────────────────────────────────────┘
进程在内核态运行时,仍然是这个进程在占用 CPU,只是执行的是内核代码。
五、为什么说”阻塞 I/O 在拷贝时阻塞”?
这里的”阻塞”指的是:
1. 从应用程序视角
ssize_t n = read(fd, buf, size); // 这个调用会"卡住"
// 在 read() 返回之前,无法执行下一行代码
printf("读取完成\n");
应用程序被”阻塞”在 read() 调用上,无法继续执行后续代码。
2. 从进程调度视角
虽然进程在内核态运行(占用 CPU),但:
- 进程无法被抢占去做其他用户态的事情
- 必须等待
copy_to_user()完成才能返回用户态
这种”不能做其他事”的状态,在 I/O 模型讨论中被称为”阻塞”。
六、更精确的术语
| 术语 | 含义 |
|---|---|
| 睡眠阻塞 | 进程状态 S/D,让出 CPU,等待事件唤醒 |
| 内核态阻塞 | 进程在内核态运行,占用 CPU,但无法返回用户态 |
阶段一是”睡眠阻塞”,阶段二是”内核态阻塞”。
七、DMA 的特殊情况
DMA 只负责设备到内核的拷贝:
设备 ──DMA──> 内核缓冲区 ← 这个过程不占用 CPU(由 DMA 控制器完成)
内核缓冲区 ──CPU──> 用户缓冲区 ← 这个过程必须占用 CPU
DMA 只负责设备到内核的拷贝,内核到用户空间的拷贝仍需要 CPU。
八、总结修正
完整图示:
阶段一:等待数据到达内核
进程状态: S (睡眠)
CPU 占用: 0%
进程位置: 等待队列
↓
【数据到达,中断唤醒】
↓
阶段二:内核→用户空间拷贝
进程状态: R (运行,内核态) ← 关键!
CPU 占用: 100% ← 关键!
进程位置: 运行队列
正在执行: copy_to_user()
↓
【拷贝完成,返回用户态】
↓
继续执行用户代码
进程状态: R (运行,用户态)
CPU 占用: 取决于代码
所以,”阻塞 I/O 在两个阶段都阻塞”的准确含义是:
- 阶段一:进程睡眠,不占用 CPU
- 阶段二:进程在内核态运行,占用 CPU,但无法执行用户代码
总结
- 同步/异步 关注的是结果获取方式(主动拉取 vs 被动推送)
- 阻塞/非阻塞 关注的是等待时的线程状态(挂起让出CPU vs 保持运行)
- 阻塞 = 进程挂起,让出 CPU,不是占用 CPU 但什么也不做
- copy_to_user() 时进程在内核态运行,占用 CPU 执行拷贝指令
- 只有异步 I/O (AIO) 在两个阶段都不需要进程参与,是真正的异步