想写这样一篇总结也是因为在互联网上看了太多乱七八糟的说法,往往让读者越看越糊涂。这些说法往往给我这样的感觉:
- 没有先讲清楚在形容什么:同步、异步、阻塞、非阻塞通常作为形容词使用,无论是用在日常语境,还是用在编程领域内部,如果你不知道这些形容词是放在什么名词之前就开始讲什么意思,甚至一段话中掺杂多个名词去形容而不说明,那就会给人不知所云的感觉。
- 概念与实现混为一谈:同步阻塞、异步非阻塞是非常常见的组合方式,所以在讲同步、异步的概念时,它们忍不住拿这些具体实现来讲概念,也就顶多能让读者理解这些固定搭配,难以活学活用(其实能不能做到前者都存疑,甚至作者自己真的懂吗?)。
- 不恰当的比喻:比喻的使用是为了帮助表达,本体和喻体之间当然是不同的事物(否则就不是比喻了),但是讲了个生动的比喻,却没有把本体与喻体之间的对应关系讲清楚,或者没有讲清楚这个比喻的边界,给话题引入了不必要的复杂性,起到了相反的作用。
所以,我打算好好聊一下我对这个话题的理解,尽可能不要犯我认为别人犯过的这些错误,欢迎读者监督。
同步与异步
同步这个词在日常语境下与计算机是有着一些不同的。日常语境下,同步经常用在同时进行的事情上,比如“多个平台同步直播卖货”,“推动新时代四化同步发展”,当然它们之间是经过协调一致的,不是无组织无纪律各行其是的。
而在编程领域,“同步”和“异步”通常用来是形容“任务”、“函数”等可执行对象的(以下统一称作“任务”);当然也有用来形容“API”的,此时它们表示的是一种具体实现的风格,即“同步风格的API”、“异步风格的API”,它跟前者有关系也有区别。
那它们究竟是什么意思呢?
无论是同步还是异步,都一定是用来形容多个任务的,我们可以说这两个任务或者这三个函数是同步或者异步执行的,但是一个任务不存在同步或者异步。同步指的是多个任务一个一个执行,不存在时间重叠的情况,异步指的是多个任务不一个一个执行,可能存在时间重叠的情况。
有的说法硬要在讲清楚这个概念之前,先把这个任务假设成一个异步函数,再把它分成调用和获取结果两个步骤,开始谈什么消息,打比方讲什么先打了个电话订餐,又接了个电话通知,然后告诉你这叫异步,这是很容易造成误解的。即便要拆成俩,单个任务的调用和获取结果也一定是一前一后,不可能先获取结果再调用,也不可能同时进行,这两个步骤是同步的,不以具体实现为转移,它们只是可能跟另一个任务是异步的,拆成俩徒增复杂度,没有任何必要。所以干脆不要拆,也不要聊具体实现,我们说一个任务,它就是一个任务,就是执行一段时间拿到结果,不要拆开。比如“吃外卖”,就等于“下单-收到电话-取餐-吃”这整个过程,反正经过了一段时间,不要在意内部细节。
那么,同步和异步应该怎么使用呢,比如;
- 我先吃了个外卖,然后打了一会游戏,吃外卖和打游戏这两个任务就是同步的。
- 我先吃了个外卖,然后打了一会游戏,又写了会代码,这三个任务也是同步的。
- 在 2. 的基础上,我在吃外卖的过程中还听了一段时间的音乐,那么听音乐和吃外卖是异步的(不要管我什么时候开的音乐,什么时候关的音乐)。
- 在 2. 的基础上,打游戏期间都是保证把音乐关掉了的,但是吃外卖和写代码期间都听了一会音乐,那么听音乐和玩游戏是同步的,和吃外卖、写代码是异步的。
是不是很简单,只要按照那个定义去套就行了。但是有一点要注意,4. 里面的 “打游戏期间都是保证把音乐关掉了” 的 “保证” 二字是很关键的,它确保了听音乐和玩游戏之间的 “一个一个执行” 的条件,这需要我在玩游戏之前确保音乐是关掉的,又保证玩游戏期间没有突然去把音乐打开,如果你只是凭运气,只是偶然造成了 “玩游戏期间音乐是关掉的” 的结果,那么它仍然是异步的,因为异步的定义里说的是“可能存在时间重叠”,你没有确保完全排除这个可能,它们就是异步的。
我们再回到日常语境下的“同步”,会发现它跟编程的“同步”是相通的。比如“多个平台同步直播卖货”,虽然整体上各平台是同时在直播一个流媒体源,看上去更符合异步的定义,但其实把“直播”拆分成“录制”、“拉流”、“转场”、“互动”、“上链接”等任务时,会发现它们之间还是存在一些比较严格的先后顺序的,比如你不可能在媒体源录制之前拉流,也不可能在某个重要互动之前就享受某些优惠。也就是说,日常语境下的同步,更多的是描述两件相关的大任务,它们的子任务只要存在一定程度的“保证”就可以说“同步”,并没有编程领域这么严格。
阻塞与非阻塞
这确实是一对很难脱离具体实现讲的概念。
不过相比同步与异步,阻塞与非阻塞更简单,是用来形容单个可执行对象(以下仍统一称作“任务”)的执行状态的。
可能被形容的有线程、协程、某一段业务逻辑等等:
- 我在某线程用 BIO 接口读一个文件,造成该线程无法继续运行,这就是线程阻塞了
- 我在某协程内使用该语言/框架提供的 sleep 方法,造成协程无法继续运行,这就是协程阻塞了(所在线程并没有阻塞)
- 我写了一段代码,反正调用 IO 或者 sleep 了,我也不知道运行在哪个线程上,哪个协程上,或者只是一个异步的 setTimeout, 反正就是在等,这就是业务逻辑阻塞了(可能线程和协程都没有被阻塞)
但其实这样说有点扩大概念外延的意思,99.9% 的情况下,我们说阻塞指的都是线程。而非阻塞,通常指的也是某个任务不会造成线程阻塞。
为什么线程阻塞是最常讨论的话题呢?因为:
- 线程的调度和空间占用,都是比较消耗系统资源的。一个 I/O 密集型业务(比如最常见的 Web 服务),如果会因为 I/O 阻塞线程,那么为了支持多个并发同时进行,就需要为每个独立的并发单独开一个线程。在并发较高时,就需要开大量的线程,占用大量 CPU 和内存资源,所以我们有了 C10K 问题。
- 这是一个已经被用各种方式花式解决了的问题了。Linux 上的 epoll、Windows 上的 IOCP、MacOS 上的 kqueue(先不要管它们是异步/同步、阻塞/非阻塞,总之 —>),都提供了可以让调用者不必开一堆线程而只需要复用固定数量的线程即可支撑较高并发的接口。不管是 Nodejs,还是 Vert.X 的异步回调,Go 的有栈协程,Rust 的无栈协程,甭管它们封装成异步风格还是同步风格的API,是甜还是咸,都是建立在前者基础上的。简单说,目的只有一个:不阻塞调用者的线程,或者说线程复用。
业务要你等3秒,网络数据包要等几百毫秒才能到,硬盘读写要有时间,这些都会造成业务逻辑的阻塞,如果使用 BIO 接口,则业务逻辑的阻塞也会导致线程的阻塞。而使用了上述的框架/语言,让你不用在这些时候阻塞自己的线程,被阻塞的就只有当前业务逻辑,线程可以去跑别的业务逻辑,实现了线程复用。
组合起来
现在,单讲同步、异步、阻塞、非阻塞,应该没什么问题了,我们再聊聊那些常见的搭配。
我们经常听说同步阻塞、异步非阻塞这种搭配方式,而如果按照前文的定义,这种搭配方式是很奇怪的——同步和异步是用来形容多个任务的,阻塞和非阻塞时是说单个任务的,这样组合在一起是在干嘛?
一般来讲,它们是用来形容某种 API 的风格和实现的。
同步和异步是表示这种 API 的接口风格。比如常见的 Linux 的 BIO 接口、Go 的 IO 接口、Rust 里的 async 函数,这些都是同步风格的接口,先后调用这样的两个接口,它们默认是同步执行的,你要异步执行,需要另外开线程或者协程;Node.js、Vert.X 的绝大部分接口,都是异步风格的接口,先后调用这样的两个接口,它们默认是异步执行的,你要同步执行,需要把第二个接口写到前一个接口的回调函数里。
阻塞和非阻塞是表示这种 API 需要调用者配合使用的线程模型。比如 Linux 的 BIO 接口会阻塞调用者的线程,它就是阻塞的,而 Go 的 IO 接口、Rust 的 async 函数、Node.js 和 Vert.X 里的异步接口,都不会阻塞调用者的线程,它们就是非阻塞的。
在没有协程的年代,同步风格的 BIO 接口就是会导致线程阻塞,它意味着心智负担小,开发方便,但是吃资源;而不会阻塞线程的 NIO/AIO 接口往往是异步风格(或者封装为异步风格),代码写起来心智负担重,性能好,比如 Node.js 和 Vert.X。所以经常有人拿同步代指阻塞,拿异步代指非阻塞,它们成为了同义词两两绑定在一起,即“同步阻塞”和“异步非阻塞”。
而 Go 、Rust 等语言提供的协程,相当于是对“异步非阻塞”的一种高级封装,可以像写同步代码那样写不阻塞线程的代码,让你即拥有高性能,又没有很高的心智负担。但是也很少见他们讨论自己是同步还是异步,阻塞还是非阻塞,因为风格上确实是同步的,封装的实际上是异步常用的非阻塞接口,确实不会阻塞线程,但是协程是可能被阻塞。所以不要套概念,理解原理,理解同步具体指的是谁和谁,异步具体指的是谁和谁,阻塞和非阻塞指的具体是谁,搞清楚对象,套定义就可以了。
总结
在写这篇文章的时候,我并没有查询很多资料,但自认为这算是一个可以简化问题,帮助理解的模型。
希望能给读者一些启发,也欢迎批评指正。