白话并发模型和异步编程范式
在编程领域,并发和异步这两个概念并没有初学者想象的那么高深,本文将以最普通的白话,拆解这两个概念,读完后下面这一系列问题,或许你就有了答案。
- 协程是什么?
- 协程和线程的本质区别是啥?
- 哪些编程语言支持协程?有栈协程和无栈协程有什么区别?
- CSP模型是什么意思?
- Actor模型是什么意思?
- I/O多路复用到底解决了什么问题?
- 线程间上下文切换的成本真的很高吗?
- 为什么用同步原语进行并发编程常常出BUG?
- 怎么解决异步回调模式的可维护性问题?
- async/await算协程吗?和Generator函数是什么关系?
- 什么是函数响应式编程(FRP)?
- 为什么Node.js要用单线程?真的只有一个线程吗?
- 为什么Java到JDK 16还没有协程?
- 为什么更优雅的Actor模型、FRP没有成为主流?
目录:
主流编程语言的并发模型
为了方便理解,我们用餐厅打个比方,贯穿整篇文章。
CPU核心,或者说Processor,是厨师。
Thread是灶台。
理想情况下,一个厨师一个灶台,厨师一刻不停的炒菜。这种情况餐厅老板是最开心的。
但是,客人不仅吃炒菜,也喜欢喝鸡汤。厨师(Processor)需要在一个灶台炖汤(I/O操作)的时候,换到别的灶台(Thread)炒菜,换灶台就叫线程间上下文切换。
于是,一系列问题就出现了。
我们先从大家熟悉的多线程编程开始讲。
三头六臂:多线程模型
典型代表:Java。
哦,放错图了。
加入客人要100锅鸡汤,最朴素的办法就是放100个灶台,每个灶台炖一锅。厨师需要有三头六臂来回奔波,哪个炖了多久、哪个要加调料,都要厨师操心。
这种方案好像除了换灶台费点事,好像也没什么大问题。编程语言实现多线程模型,大多基于操作系统提供的原生线程,再包装一层出来给开发者用。而封装的线程和OS线程是一对一关系,线程调度完全交给操作系统就完事了,编程语言的Runtime实现也相对简单。
不过,当生意越来越好,老板雇两个厨师来一起干(多核并行编程),问题就出来了。对于同一锅鸡汤:
- 1号厨师放过盐了,2号厨师不知道又放了一次,客户非常生气;
- 两个厨师都以为对方放了盐,结果都没放盐,客户非常生气;
- 1号厨师和2号厨师正巧准备一起放,两人互相扯皮了半天还没放,所有客户都非常生气。
这叫竞态条件(Race Condition)。解决这类问题,需要用到各种同步原语:从CPU硬件层面的CAS指令;到OS级别的临界区、信号量、互斥量;再到编程语言的原子类型、各种锁、同步栅栏、并发安全的集合,都是让内存数据能被多核CPU安全地修改。
除了同步原语,有没有其他办法呢?餐厅的老板很聪明,想到了两种新的方案,下面两节分别介绍。
万剑归宗:I/O多路复用 + 单线程模型
典型代表:JavaScript。
回顾上一节那个餐厅的难题:炖的汤越来越多,换灶台要时间,灶台太多放不下,厨师多了会打架。
餐厅老板脑袋一拍:咱就炖个汤还请那么多厨师干嘛?就雇一个厨师不就好了吗!什么,厨师忙不过来?雇勤杂工!
厨师放调料要1秒,炖的过程要等1小时。类比计算机世界也类似,不同部件的执行速度严重失衡:CPU计算 >> 主存读写 >> 网络或文件I/O。
- CPU很快:1核CPU在一眨眼的功夫,100毫秒,就可以执行数亿条指令。
- I/O很慢:如果一次主存访问想象成1天的话,一趟局域网数据传输就要13.7年。
老板雇了勤杂工之后,炖鸡的这些锅,全都交给勤杂工一起批量照看就好了,大厨只负责在恰当的时候放调料。脑补一下关东煮就明白了:
我们把每个小格子想象成一个Socket连接,这就叫I/O多路复用。
在餐厅里:有一个手持任务队列、名为EventLoop的大厨线程,加上N个任劳任怨的勤杂工线程一起干活。
以Node.js为例,厨师是这样的:
cooker.on("该放调料了", () => {
console.log("一眨眼功夫就放好啦")
})
苦逼的勤杂工们,被关在一个叫“libuv”的小黑屋里,切到内核态进行系统调用,干着类似这样的活:
// 设置Socket非阻塞
setnonblocking(socket_fd);
ep_fd = epoll_create(max_events);
while (true) {
// 被困在系统里的“勤杂工”
epoll_wait(..)
// 阻塞式读取数据,交给大厨处理
}
从这个模型来看,I/O多路复用,本质上是解决了I/O与计算职责分离的问题。当网络I/O的脏活、累活分离出去了,只要一位大厨,炖百万只鸡不在话下。Node.js、Redis都是这么干的。篇幅原因,只简单说明了网络I/O的案例。定时器、文件I/O等异步任务的实现原理是不一样的,和I/O多路复用无关。
然鹅,这里有一个巨大的隐患。
如果不仅要做关东煮或者炖鸡(I/O密集型任务),还时常要做炒菜(CPU密集型任务)怎么办?
协程,该出场了。
千手观音:协程模型
典型代表:Erlang / Golang。
为了解决既要炒菜又要炖汤的问题,聪明的餐厅老板又脑袋一拍,想到一个万全之策:
- 多雇几个厨师,但是每个人只管面前这一个灶台 —— 不需要线程切换,有多少核CPU就建多少OS线程;
- 不用增加灶台,但要购置一大批锅 —— 这些“锅”就是协程(coroutine);
- 之前雇的勤杂工继续照看炖鸡的锅和关东煮的锅 —— 继续保留 非阻塞I/O + I/O多路复用 的优良传统;
- 再雇一波勤杂工,就叫他们“换锅侠”吧,专门负责看厨师们面前的锅有没有炒好、放好调料,弄好了就立刻帮厨师换锅 —— 协程调度。
协程最核心的特点就是:不用OS线程的上下文切换,在用户态实现超轻量的执行单元调度。协程的实现有很多,可以根据协程之间有无调用栈分为有栈协程和无栈协程;也可以根据协程间是否存在从属关系分为对称协程和非对称协程。
多面手: 你有我有全都有
其实,编程语言的演进过程也是互相“借鉴”的过程,最后的结果就是“你有我有全都有”。其中最典型的“借鉴”就是Generator函数,搭配async/await或yield实现无栈协程。
async/await模式我们在下面讲异步编程范式再细谈,是一个兼顾实现成本、迁移成本、性能、可维护性的方案,因此多数主流编程语言都可以看到async/await协程的身影。比如:
- C++(20): co_await/co_return/co_yield
- C#: async/wait, yield
- JavaScript: async/await, yield
- Dart: async/await, yield
- Python:async/await, yield
注:
- 虽然C++ 20在语法层面提供的是无栈协程,C/C++ 生态也有诸多使用汇编或其他方式实现的有栈协程库。
- 执行线程是单线程的Node.js,也早已提供了多线程的支持,用于处理CPU密集型任务。
异步编程范式
上面一节我们讲了3种并发模型,基于编程语言实现的并发模型,又演化出了多种异步编程范式。下面一节,我们逐个讲解各类异步编程范式,仍然举餐厅的例子,设想一个场景:现在想让厨师做鸡汤给我们喝,需要 startBoil/coolDown/drinkSoap 3个耗时操作。
在讲异步编程范式之前,我们先看同步编程是怎么做的。为了保持简洁,假设都在一个线程执行了,不涉及多线程间共享数据。
new Thread(() -> {
startBoil();
coolDown();
drinkSoap();
}).start();
异步回调模式
这三个操作要转换成异步调用模式,最直观的解决方案就是用回调函数。什么是回调函数呢?
声明一个函数,把函数指针丢给调度器,这次I/O搞定了就来执行它,这就是一个Callback。
startBoil(function callback() {
console.log("炖好了,撒点盐")
coolDown(function callback() {
console.log("现在可以喝了")
drinkSoap(function callback() {
console.log("嗝~~")
})
})
})
虽然避免了线程间上下文切换的问题,但这样写异步代码,写着写着屏幕就不够宽了。嵌套回调越来越深,变成了回调地狱,下面几节就是常见解法。
- async/await/yield:熨平callback嵌套褶皱;
- 发布订阅模式:把callback丢出去不管了;
- 函数响应式编程:把callback做成烤串;
- CSP模型/Actor模型:回归同步调用,放到有栈的协程/线程。
Callback的蜕变:async/await范式
async/await本质上是编程语言对generator/yield的一层语法糖。懂了Generator也就明白了async/await的原理,以及为什么Generator函数可以熨平回调函数的嵌套褶皱。
具体的原理分析网上有很多文章,比如这个Node.js的:async/await 源码实现
一句话概况就是:Generator可以看做状态机,遇到yield就进入Pending状态出让执行权,遇到resume/next就继续执行,直到下一个yield。编程语言或者SDK把generator函数包装成 async/await 关键字,就能在看似同步的代码块中异步执行,由于Generator实现的协程是在当前栈顶上继续调用函数,只能模拟携带上下文,并不是真正的保存当前上下文,切换到另一个协程栈,因此是无栈协程。
async () => {
await startBoil();
await coolDown();
await drinkSoap();
}
这种看上去像同步的调用,让心智负担大大降低,也是实际工程中权衡利弊的工程中非常实用的方案。
不过,async/await存在一个小问题:关键字传染。
async () => {
someArray.forEach(async () => {
// 只要调用链底存在异步,一条链全部都要带上async关键字
})
}
下次新入职的前端遇到"SyntaxError: await is only valid in async functions"报错的时候,你就可以拍拍她:写Generator状态机实现的无栈协程的时候会出现async关键字传染问题,显式告诉V8引擎,即可解决这类问题。
Callback的涅槃:函数响应式编程
除了async/await/yield,异步回调地狱还有另一个解法 —— 函数响应式编程(FRP)。
FRP简单的理解可以认为是:
函数响应式编程(FRP) ≈ 函数式编程(FP) + 发布订阅模式
在讲FRP之前,我们先复习一下“发布订阅模式”。
// cooker.js
eventBus.on("炖好了", () => {
eventBus.emit("可以喝了")
})
// eater.js
eventBus.on("可以喝了", () => {
console.log("嗝~~")
console.log("对了,汤咋做出来的,炖汤之前嘎哈了?")
})
发布订阅模式在异步编程中,从另一个维度解决掉了回调地狱问题。让Event Bus统一管理一大锅Callback,每个Callback挂了一个onXXXEvent的标签,来了什么异步事件,就让Event Bus统一来调用对应的函数。
既然一个Event Bus就解决了回调地狱问题,为什么还要函数响应式编程呢?
因为事件模式,解开了Callback,会带来逻辑碎片化的问题。也就是说,完全靠Event Listener无法写出高内聚的代码。
那有没有办法,把碎片化的回调函数整合起来,让代码重新内聚呢?
有的。萝卜加大棒,听说发布订阅模式和函数式编程更配哦?
函数式编程(FP)是一个自古有之的概念,把一沓纯净的函数声明式地组合起来,理论上就可以实现任何功能。事件驱动的异步回调函数,经过FP的洗礼,变成了Callback烤串,外酥里嫩。
我理解的函数响应式编程:就是通过一系列操作符对函数组合,实现复杂的异步事件流的操纵和处理,异步事件与函数式编程的完美结合。
比如下面是一段用RxJS实现异步事件流处理的代码实例,没有回调地狱,也不需要async/await。
// incoming$ is a stream that flows
MessageBus.incoming$.pipe(
// delay messages after call pauseDispatcher()
delayWhen(() => this.pauseWhenUnAvailable()),
// dispatch input messages, transform input stream to output stream
mergeMap((input) => this.handleCommand(input)),
// publish output messages
mergeMap((output) => this.sendAckIfNeed(output)),
// record output
tap((output) => {
this.logOutput(output);
})
).subscribe(() => {
// do something
})
脑补一下植物大战僵尸游戏里,biubiubiu的豌豆射手,源源不断的豌豆异步发射出来,经过火炬的Pipeline变成了火豌豆,最终真正起作用是在砸到目标的瞬间,也就是上面subscribe里的逻辑。
FRP范式下经常提到背压控制,我们脑补一下水坝,上游的水流速度时快时慢,但水坝可以缓冲整流,让下游流速非常平缓,在传统编程范式要实现复杂的整流逻辑挺复杂的,而在Rx中实现“水坝”功能,仅仅需要一个操作符。这种操作符组合的黑魔法,尤其适合作为框架层的实现基础。因此,大家熟知的Java界新秀:Vert.x, WebFlux 等框架,前端的Angular/Vue/React框架都有FRP的影子。
CSP/Actor模型:一切皆消息
Do not communicate by sharing memory; instead, share memory by communicating.
上面这句名言,Share Nothing架构,也解释了CSP模型和Actor模型的共性:把编程问题转换为通信问题,不同的执行单元不共享同一份内存数据,因此就不需要任何同步原语控制共享数据的访问。
我们先说CSP模型,CSP是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的 channel 进行通信的并发模型。Golang用channel炖鸡汤的代码如下:
package main
import (
"fmt"
"time"
)
func main() {
boiled := make(chan struct{})
drinkable := make(chan struct{})
finished := make(chan struct{})
go func() {
fmt.Printf("开始炖\n")
time.Sleep(time.Second)
fmt.Printf("炖好了\n")
boiled <- struct{}{}
}()
go func() {
<-boiled
fmt.Printf("凉一凉\n")
time.Sleep(time.Second)
fmt.Printf("凉好啦,可以喝了\n")
drinkable <- struct{}{}
}()
go func() {
<-drinkable
fmt.Printf("喝完啦\n")
finished <- struct{}{}
}()
<-finished
}
注:Golang虽然部分实现了CSP模型,但语言本身也允许共享内存数据,如果不用channel机制,直接多协程更新共享数据,不正确使用同步原语也一样会出现并发BUG。
至于常常一起被提到的Actor模型,我们常说Erlang/OTP、Scala-Akka就是典型的Actor模型(虽然Erlang的诞生比Actor模型概念的提出更早,Erlang的作者也不认为Erlang是Actor模型),其主要特点也是在于把编程问题转换为通信问题:
- Actor之间完全不存在共享数据,创建Actor非常廉价;
- 每个Actor,在Erlang中叫微进程,有自己的执行栈,互相之间完全隔离;
- 每个Actor有一个“邮箱”, Actor之间通过收发邮箱通信。
与CSP模型不同的是,Actor模型更进一步,每个独立的并发实体都有一份自己的“Channel”,在Erlang中叫“具名邮箱”。可以看出,这种抽象非常适合消息相关的领域,比如曾经WhatsApp增长到9亿用户也只有50人维护的聊天服务器、Zoom的聊天服务器、一些著名的分布式消息队列组件,都是用Erlang开发的。
因此,简单的理解Actor模型就是:有栈协程/线程 + 发布订阅模式 + Share-nothing 架构。CSP模型与Actor更细节的原理可以阅读这篇文章:并发之痛 Thread,Goroutine,Actor
结合对OOP和FRP的理解,我自创了一个词来概括CSP/Actor范式 —— 面向对象响应式编程(OORP)。OORP可以看作是原教旨面向对象编程在异步事件流场景下的特化产物。
到底哪个范式最好?
我们从主流编程语言的并发模型出发,了解了几类异步编程范式及其演化历程,学习了5种“喝鸡汤”的姿势。那么,这些并发模型下的异步编程范式,哪个最好?
理论 vs 现实
理论上,上面介绍的4种范式中:函数响应式编程、Sharing noting的Actor模式 似乎是最优雅的解决方案; 实际上,目前世界上大部分代码,都是在用同步编程或Async/Await的假装同步,这两个看似一堆缺点的方案。
为什么会这样呢?
其原因我们从FP的发展历程可以看出来。猿界有一支神秘的学院派函数式编程的崇拜者,念叨着函子,单子、纯函数、柯里化之类的咒语,膜拜Lisp、Pascal,鄙视新泽西派简陋的C、C++、Golang、蓝领语言Java。从工程师的视角看,FP的确在一些基础库和特定领域解决了非常关键的问题,但很难成为软件系统中的砖头和水泥。
类似的,Actor模型、FRP这类技术或许一直将是小众选型,因为:
- 世界是不确定的:世界充满不确定的变化,无法用完美的模型来表示,打补丁才是常态;
- 人脑带宽有限:当一种知识,学习它的心智成本过高,学习它的人数就会呈幂律分布骤减。
现实世界常常是 Worse is better。能解决掉实际问题的技术,就会有市场,不管我们是认为它们是好还是坏、优雅还是丑陋,即使是被诟病的回调模式也有应用场景。
插曲: 为什么Java至今没有协程
广泛使用的Java就是线程池、JUC类库撸到底,协程是什么,我不听,我不听。Java官方的协程特性支持(Project Loom)从JDK 14就说要发布了,难产了好几个大版本,至今还没生出来。
那么,为什么Java头这么铁,是道德的沦丧还是人性的扭曲?
其实也不能怪Java,这里有一系列很棒的回答:为什么Java坚持多线程不选择协程?,总结一下主要原因有:
- 数据库操作无法协程化,20多年的JDBC标准就是同步的、一个连接一个线程。其他部分花里胡哨的NIO/Reactor也没法根除线程池模式,除非不用JDBC;
- 大部分网络I/O已经被Netty们剥离出去,性能瓶颈问题已经解决大半,JDBC的数据库I/O剥不出去也没太大问题,毕竟大部分数据库自己就处理不了太高并发;
- 同步编程的业务线程池就算切换白耗一点CPU又咋地了,毕竟JVM已经那么吃内存了,Spring全家桶各种AOP、反射的损耗已经那么多了,不在乎再多耗一点;
- 同步编程模式符合直观思维模式,已经深入广大JAVAer的心,Reactor/ReactiveX那一套即使学会了,习惯了传统的Java编程模式的开发者用起来也别扭;
- 历史包袱太沉,核心生态对线程池、ThreadLocal、JDBC这些东西的依赖太强,迁移成本很高,从Project Loom的Virtual Thread的设计也可以看出来;
- 其实线程上下文切换的开销也没有那么恐怖,现代CPU可以做到约每秒33万次线程切换,一次耗时约3μs,即使相比于Golang的协程切换慢了30倍,这些开销也可以接受。
没有协程的Java活的很好。其实多线程模型下,内存数据共享也不是原罪,关键在于数据共享时,执行上下文自动被外部调度器切换了才是BUG之源,于是需要依靠同步原语和头发稀疏程度来保障不出BUG。
因此,Java的面试总要问成堆的并发、同步栅栏、锁、线程池问题,JavaScript的面试就不会。
结语
哪个语言的并发模型最好、哪种异步编程范式最好,不会存在标准答案。
对于编程语言和范式的选择,也不一定是单选题。实际开发中,我们完全可以混合范式编程,对特定的业务类型应用特定的编程范式,找最优解对付现实问题。
从异步编程范式,归结到面向对象编程与函数式编程,这二者像是编程领域的的波粒二象性。面向对象是粒,函数式是波:面向对象更关注数据结构,强调信息隐藏、消息传递;而函数式编程更关注行为,由变而生、一切皆函数。
不管黑猫白猫,抓到老鼠就是好猫。