透彻理解阻塞/非阻塞、同步/异步
潘忠显 / 2022-02-10
阻塞/非阻塞、同步/异步这几个词汇常用在与 I/O 相关的接口、实现、框架、协议的描述。
在一些场景下,同步与阻塞表示几乎相同的含义;在另外一些场景下,同步和非阻塞也可以一起描述接口。
甚至针对同一描述对象,我们从不同角度去看待,都可能选用截然相反的词进行描述。在某个层次上表现为阻塞/非阻塞、同步/异步,也并不意味着其实现中一定使用了相同的手段。
本篇小文是我个人对这几个词汇的理解过程,错误之处望大佬们不吝指正。
一、字面含义
我们先分别看看 阻塞/非阻塞、同步/异步 的字面含义,能对这两组概念有最简单直观的理解。
1.1 阻塞(block)
直接通过 Google 图片来理解一下 “block” 的含义,就是前进的道路被阻挡了:
这里要区分另外一个在线程同步时提到的概念 屏障(barrier):
- block 是针对单个调用者,满足条件就解除阻塞
- barrier 是拦下多个并行的线程,等待都到达后再放行,条件就是大家都到达
1.2 同步(synchronous)
synchronous 这个词,英文单词拆解开由三部分组成,syn-chron-ous,就是在时间上是一起的,通常译作同步的、同时的:
-
syn-, prefix. comes from Greek, where it has the meaning “with; together.’'
-
-chron-, root. comes from Greek, where it has the meaning “time.’'
-
-ous, suffix. attached to roots to form adjectives with the meaning “possessing, full of (a given quality)’'
通常这个单词还表示速度、位置上一致,比如地球同步卫星,叫做 “geosynchronous satellite”。
同步是同时发生的,在时序上可预测;异步不是同时发生的,在时序上不可预测。
更广泛点,如果 A 做了一件事的同时,B 去做另外一件事,如果 A 不做这件事,B就不去做另外一件事,这也是一种同步。
- HTTP/1 是同步的协议,客户端发出一个消息,预期发送完后接收一个消息。HTTP 的 Client/Server 的实现可以是异步的。
- 发邮件是异步的,因为发出去无法期待着什么时候回复、甚至会不会回复。
二、阻塞与同步关联与比较
2.1 联系
两组概念字面意思看似没有什么关系,为什么我们还会经常混淆这两组概念?
因为在计算机领域,经常会有这样的场景,将同步和阻塞紧紧的关联在一起:
用户调用 API,它会挂起线程,直到它得到某种答案并将它返回给用户。
-
阻塞:API 没有立即返回,不能继续往下进行。
-
同步:当 API 没有返回时,不去做后边的动作;当 API 返回之后,就去做后边的动作。
-
阻塞和同步概念都常用于描述 I/O 尤其是网络 I/O 接口或框架 。
比如在 gRPC 的文档中,介绍 同步 RPC 调用 和 非阻塞 API 时是分别有两段描述:
Synchronous RPC calls that block until a response arrives from the server are the closest approximation to the abstraction of a procedure call that RPC aspires to.
This tutorial shows you how to write a simple server and client in C++ using gRPC’s asynchronous/non-blocking APIs.
可见同步和阻塞常常一块使用,异步和非阻塞常常一起使用。
2.2 区别
阻塞和同步两个都是形容词,但它们描述的细节有不同偏重:
- 描述词性不同,阻塞描述的是阻拦的这个动作,同步/异步描述的是架构状态
- 描述层次不同,阻塞常用于描述单模块中的 API,更微观;同步/异步则描述多个模块关系,更宏观
- API 也可以用同步描述,能够给出预期内结果的就是同步 API,反之则是异步 API
根据这两点,我们就能更好的理解,上边介绍的为什么 gRPC 中叫同步 RPC 调用(Synchronous RPC calls) 而不是称作非阻塞 RPC 调用:
- 调用 (call) 是涉及客户端和服务端两个模块
- 预期在的发送请求之后会有返回
2.3 组合
通过本节前面的介绍,可以理解 同步/异步 和 阻塞/非阻塞 是两个方面的描述,因此这两方面概念可以组合出四种情况来描述接口或 I/O 模型,表中给出了每种组合下的案例:
阻塞 | 非阻塞 | |
---|---|---|
同步 | read() / write() gRPC 同步 API |
read() / write() + O_NONBLOCK |
异步 | I/O 多路复用 (select() ) |
aio_read() / aio_write() gRPC 异步 API |
后边会继续对其中的案例进行分析说明。
三、案例分析
为了加深理解,我们通过一些常见的案例来理解一下阻塞、非阻塞、同步、异步以及它们的组合。
3.1 同步接口 read()
/ write()
为什么说 read()
接口是同步的?因为无论是打开文件时,是否增加了 O_NONBLOCK
标志位, read()
函数执行之后,我们能够明确的知道它是否执行成功。
- 阻塞模式下,只有读到数据或者发生错误时有返回
- 非阻塞模式下,能够立马读到数据,或返回非零并设置对应的
errno
(打开文件时oflag
中带了O_NONBLOCK
,则read()
会立马返回,如果失败会设置为EAGAIN
orEWOULDBLOC
标志位。)
这里说的同步,是指 read()
自身对于程序流的表现是同步的,当然 read()
更底层实现上对数据的处理可以是异步的。在 APUE 14.5 节中也有提到,“并不能把非异步 I/O 函数称作‘同步’的”。这里加引号的“同步”是指完成了真正的读写,比如 write 完成并落到磁盘持久化。
3.2 I/O 多路转接
I/O 多路转接(I/O multiplexing)的基本思想是将多个描述符设置为非阻塞的,然后将这些描述符加入感兴趣的描述符列表中,然后阻塞直到其中的某个描述符准备好进行 I/O 时返回。
select()
等函数本身是同步阻塞函数,为什么有些文章会将 I/O 多路转接复用作为异步阻塞的案例?下面谈谈我的理解。考虑 APUE 14.4 节上的这个图:
telnet 命令执行的时候,实际是有两个 fd,一个是与用户端终端的接收输入和将服务端的返回输出给用户,另外一个与则是 telnet server 之间的用于将用户的输入传入,并接收其返回。
当这两个 fd 建立起来之后,对这两个 fd 使用 I/O 多路转接后,因为无法预测是用户先输入的还是远端的机器先有返回,即对不同的 fd 的处理时序是无法预测的,也就是异步的。
3.3 gRPC asynchronous/non-blocking APIs
上文提到,gRPC 描述 API 时使用了 asynchronous/non-blocking:
-
“asynchronous” 是
AsyncSayHello()
这种接口不会返回状态,调用这个函数之后无法预期是否发送或者是否收到返回,而是需要通过Finish()
+cq.Next()
接口来获得之后结果的 -
“non-block” 描述的是调用之后不会阻塞住的
3.4 I/O 模型
详见 UNP 6.2 节。
POSIX 在对 Synchronous I/O Operation 的定义中,同步和阻塞几乎是等价的:
An I/O operation that causes the thread requesting the I/O to be blocked from further use of the processor until that I/O operation completes.
Note: A synchronous I/O operation does not imply synchronized I/O data integrity completion or synchronized I/O file integrity completion.
3.5 异步 I/O
详见 APUE 14.5 节。
3.6 事件驱动
与事件驱动(Event-Driven)类似的表述还有 Event-Based / Event-Engine。
非阻塞 I/O 本身并不能直接带来性能的提升,而是需要结合 select/epoll 等多路复用机制。libevent 等事件驱动框架,对不同平台上的这些机制进行封装,将 fd 上发生可读、可写、超时等抽象成事件,指定在 fd 上发生某事件时进行处理的回调函数,实现整体功能。