IO 同步、异步、阻塞、非阻塞概念区分
你讨论的四个词经常被混用,根因是它们来自不同语境:有的描述调用方线程是否等待,有的描述完成通知与结果交付方式。结论先给:Blocking / Non-blocking 是“等待策略”,Synchronous / Asynchronous 是“完成模型(completion model)”;在 IPC(消息传递)教材语境里,部分作者会把“同步/异步”直接当作“阻塞/非阻塞”的别名,但在 I/O 系统调用与网络编程语境里二者通常需要分开讲清楚。
1. 概念总览
1.1 两个维度:等待策略 vs 完成模型
把一次“调用”拆成三个问题,概念就不容易打架:
- 等待策略(Blocking / Non-blocking):调用方线程在“暂时无法完成”时,会不会进入睡眠等待(sleep),从而让出 CPU。
- 完成模型(Synchronous / Asynchronous):这次操作的“完成”由谁来驱动,以及结果如何交付给调用方。
- 就绪通知(Notification):如果不在当前调用栈里拿结果,那么通过轮询(polling)、事件(eventfd、signal)、回调(callback)、Future 等哪种方式通知。
阻塞与同步、非阻塞与异步在很多文章里被绑在一起,是因为它们在某些特定语境下确实经常同时出现,但并非逻辑必然。
1.2 四个词的更严谨定义
| 术语 | 关注点 | 典型表现 | 核心判据 |
|---|---|---|---|
| 阻塞(Blocking) | 等待策略 | 线程被挂起(sleep),等待事件发生再被唤醒 | “当前线程是否会睡眠等待” |
| 非阻塞(Non-blocking) | 等待策略 | 立即返回;要么成功一部分,要么失败并告知“暂时不可用” | “当前线程是否会立即返回到用户态继续执行” |
| 同步(Synchronous) | 完成模型 | 调用返回时,操作在语义上已经完成(结果已可用) | “完成点是否在当前调用路径上” |
| 异步(Asynchronous) | 完成模型 | 调用返回时操作可能尚未完成;未来以通知方式给出完成结果 | “完成点是否在未来的某个时间点被通知/收割” |
注意:这里的“完成”必须在语境里定义清楚。对 read() 而言,“完成”通常指数据已经拷贝到用户缓冲区;对网络发送而言,“完成”可能只意味着数据进入内核发送缓冲区,并不等价于对端已收到。
2. 分层语境:端到端路径里“同步/异步”必须指明层级
你描述的“网络包从应用程序 A 到应用程序 B”的路径是正确的直觉:它不是一次函数调用,而是一条跨越多层边界的流水线。同步/异步只能针对某一层的某个接口(API/协议边界)来讨论,否则很容易把“内核内部异步流水线”“框架接口异步”“系统调用是否阻塞”等概念搅在一起。
2.1 端到端路径与接口边界
一个常见的数据流可以粗略画成:
- A 业务代码
- A 框架接口(回调、Promise、协程、Future 等)
- 系统调用边界(用户态 -> 内核态):
read/write/send/recv - 内核网络栈与驱动(socket 缓冲区、协议栈、队列、网卡驱动)
- 网卡硬件(ring buffer + DMA)与链路传输(以太网、交换机/路由器转发)
- B 侧网卡硬件与驱动
- B 侧系统调用边界(内核态 -> 用户态)
- B 框架接口
- B 业务代码
每一段都可以有自己的“是否等待”“如何通知完成”“返回值代表什么”。把它们强行当成“前一级调用后一级返回结果”的同步调用链,会导致把不同层面的语义混为一谈。
2.2 框架接口的异步:是对业务代码暴露的编程模型
例如 JavaScript 的异步 HTTP、回调风格 API、协程 await、Future/Promise,本质是:业务线程不在当前调用栈里等待完成,而是把“后续逻辑”注册为回调/续体(continuation)。
关键点:
- 框架接口是异步的,并不必然意味着它底层一定用了非阻塞系统调用;很多框架会用线程池 + 阻塞 I/O 来实现“对上异步”。
- 反过来,框架也可以对上提供看似“同步阻塞”的 API(例如 Go 的网络 API),但在运行时内部用非阻塞 I/O + 事件循环把等待隐藏起来。
2.3 系统调用语义:大多数 read/write 仍是同步完成模型
在 POSIX/Linux 语境下,常见 read/write/recv/send 的“同步”体现在:只要系统调用返回成功,它所承诺的那部分效果已经在返回前完成。
read/recv:返回的字节已经从内核缓冲区拷贝到用户缓冲区(除非使用mmap等不同机制)。write/send:通常只保证数据被拷贝/排队到内核缓冲区(例如 socket 发送缓冲区),并不保证已上网线、更不保证对端已收到。
阻塞与非阻塞影响的是:当“暂时无法满足语义”(例如 socket 缓冲区空/满)时,这个系统调用是睡眠等待还是立即返回 EAGAIN/EWOULDBLOCK。因此,“非阻塞 I/O = 异步 I/O”在系统调用语义上通常是不成立的:非阻塞系统调用大多仍是同步完成模型,只是改变了等待策略。
2.4 内核到网卡:DMA + 中断是内部异步流水线,不等价于用户态 AIO
内核把数据交给网卡通常依赖 ring buffer 描述符与 DMA(不是 DMI):CPU/驱动把描述符挂到队列里,网卡硬件用 DMA 在内存与网卡缓冲区间搬运数据;发送/接收完成后再通过中断或 NAPI 轮询等机制让内核“知道进度”。
这套机制从“CPU 是否在忙等”角度看是异步的,但它描述的是内核内部与设备协作的实现细节,并不直接决定你在用户态看到的是同步 API 还是异步 API。
2.5 链路与转发:不是“同步调用握手”,而是按帧/包的异步传输
以太网与交换机转发更像是“按帧/包排队并尽力转发”的模型:
- 发送端把帧发到链路上,接收端持续监听并按物理层编码恢复时钟采样数据;不存在“每发一帧都要等对端就绪再开始”的同步调用语义。
- 交换机/路由器转发引入排队、拥塞与丢包;可靠性与顺序通常由更高层(例如 TCP 重传与滑动窗口)提供。
因此,把“以太网是同步时序逻辑,所以这一步是同步”的说法容易误导:它属于硬件电路层面的“同步/异步设计”概念,和我们在 API/协议语义里讨论的“同步/异步 I/O”不是同一个维度。
3. IPC(进程间消息传递)语境:同步/异步常被当作阻塞/非阻塞别名
3.1 教材式定义:send/receive 的阻塞与非阻塞
在 IPC 的“消息传递(message passing)”模型中,最常见的基本操作是 send() 与 receive()。许多操作系统教材会把“阻塞式消息传递”称为 synchronous,把“非阻塞式消息传递”称为 asynchronous。
关键点是:这套语境里讨论的是进程(或线程)在 send/receive 上是否等待,因此同步/异步与阻塞/非阻塞容易被当作同义词使用,但依然需要区分主体:发送方与接收方。
3.2 发送方与接收方可以自由组合
| send 语义 | receive 语义 | 结果直觉 |
|---|---|---|
| blocking send | blocking receive | 两端都可能睡眠,直到消息匹配成功 |
| blocking send | nonblocking receive | 发送方可能等到对端接收;接收方可“探测式”收取 |
| nonblocking send | blocking receive | 发送方不等;接收方等消息到达 |
| nonblocking send | nonblocking receive | 双方都不睡眠,靠轮询或事件机制推进 |
这里的“同步/异步”如果被当作别名使用,通常仅覆盖“阻塞式/非阻塞式”的含义,不必强行映射到 I/O 编程里更细的“完成通知模型”。
4. I/O 系统调用语境:Blocking / Non-blocking 与 Sync / Async 需要拆开讲
4.1 阻塞 I/O:调用方睡眠等待条件满足
以 read(fd, buf, n) 为例(Unix/POSIX 常见语义):
- 如果
fd当前“不可读”(例如 socket 接收缓冲区为空),阻塞read()会让当前线程睡眠。 - 内核在数据到达后把线程唤醒,
read()返回实际读取的字节数。
从调用方视角看,它通常也是“同步”的:因为返回时结果已在 buf 里。
4.2 非阻塞 I/O:不睡眠,立即返回 EAGAIN/EWOULDBLOCK
当 fd 被设置了 O_NONBLOCK(例如通过 fcntl),read() 的语义变化为:
- 若当前不可读,立即返回 -1,并设置
errno = EAGAIN或EWOULDBLOCK。 - 若可读,尽可能读出“当前可用的数据”,可能小于期望长度。
这种模式通常仍是“同步”的:因为只要 read() 返回正数,那些字节就已经同步拷贝到了用户缓冲区;区别在于不可读时不让线程睡眠,由你决定何时再试。
// nonblocking-read.c(示意)
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
ssize_t n = read(fd, buf, sizeof(buf));
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 当前没数据,立刻返回;不要在这里做 busy loop
}
4.3 I/O 多路复用:用“就绪事件”消除无意义重试
非阻塞 read() 的问题是:你不能靠 while 循环一直试(busy loop),否则 CPU 会空转。工程上通常配合 I/O 多路复用:select/poll/epoll(Linux)或 kqueue(BSD)。
其本质是:把“等待多个 fd 变得可读/可写”的阻塞,集中到一个点,而不是把每个连接都配一个阻塞线程。
// epoll-loop.c(示意)
// 1) 所有 fd 设为 O_NONBLOCK
// 2) epoll_wait 阻塞等待事件(等待发生在这里)
// 3) 事件到来后,对就绪 fd 调用 read/write(不阻塞或尽量不阻塞)
这类模型经常被口语化为“非阻塞 I/O”,但严格说它是:非阻塞系统调用 + 事件通知(readiness notification) 的组合。
4.4 异步 I/O(AIO):提交请求后,内核在未来交付“完成事件”
异步 I/O 的关键是:read 的“完成点”不在当前调用路径上。
- 你提交 I/O 请求后立即返回(submit)。
- 内核在未来某个时刻把“完成事件”交给你(completion),你再去收割结果。
在 Linux 上,典型实现包括 POSIX AIO(aio_*)与 io_uring。注意:异步 I/O 也可以在等待完成事件时选择阻塞(例如等待 completion queue),所以“异步”不等价于“永远不阻塞”。
5. 四个概念的组合关系(以及为什么很多解释会自相矛盾)
5.1 一张更抗误解的组合表
| 组合 | 典型例子 | 你会看到的现象 |
|---|---|---|
| 同步 + 阻塞 | 阻塞 read() |
调用线程睡眠,返回时拿到完整结果 |
| 同步 + 非阻塞 | O_NONBLOCK + 直接 read() |
立即返回;成功则拿到已拷贝数据,失败则 EAGAIN |
| 异步 + 非阻塞 | io_uring 提交 + 事件收割 |
提交即返回;未来通过完成队列拿到结果 |
| 异步 + 阻塞(等待完成) | 提交异步任务后 future.get() |
API 是异步的,但你选择用阻塞方式等结果 |
因此,“同步就是不返回,所以一定阻塞”这种说法只在把“同步”偷换成“阻塞等待”时才成立;一旦你把“同步”理解为“完成点在当前调用路径”,同步完全可以是非阻塞的(要么立即成功,要么立即告知暂时不可用)。
5.2 常见误区清单(面试高频)
- 把“同步/异步”当作“是否返回”的区别,而不定义“完成”的语义。
- 把“非阻塞”理解成“性能更高”。非阻塞只是把等待从内核的睡眠队列移动到你自己的状态机里,吞吐量提升依赖事件驱动与资源模型是否匹配。
- 把 “epoll = 异步 I/O”。
epoll是 readiness 通知,不是 completion 通知。 - 把“内核里用 DMA/中断”推导为“用户态就是异步 I/O”。前者是实现方式,后者是接口语义。
- 认为 “异步一定不阻塞”。你随时可以选择在收割完成事件时阻塞等待。
6. 为什么非阻塞与事件驱动能提高吞吐量:从资源模型解释
6.1 线程资源与切换开销
“每连接一线程/进程”的阻塞模型在高并发下主要瓶颈通常不是单次 I/O,而是:
- 线程栈内存与调度结构的额外开销。
- 频繁的上下文切换(context switch)与缓存失效。
- 锁竞争与共享数据结构争用(多线程时)。
6.2 事件循环的本质收益
事件驱动模型把“等待”集中在 epoll_wait 之类的点上:
- 少量线程可以管理大量连接。
- 就绪后用非阻塞
read/write尽快把数据搬进应用层,再交给业务流水线。
代价也明确:需要状态机、背压(backpressure)、连接级别的公平性与超时控制,代码复杂度显著增加。
7. 面试回答
7.1 一句话定义
- 阻塞/非阻塞:线程在资源不可用时会不会睡眠等待。
- 同步/异步:操作完成点是否在当前调用路径上,结果是直接返回还是未来通过通知交付。
7.2 两句补
- 在 IPC 消息传递教材里,同步/异步常被用作阻塞/非阻塞的别名,但在 I/O 语境里要分开。
epoll是 readiness 通知,AIO(如io_uring)才是 completion 通知。