Skip to content

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 = EAGAINEWOULDBLOCK
  • 若可读,尽可能读出“当前可用的数据”,可能小于期望长度。

这种模式通常仍是“同步”的:因为只要 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 通知。