速成课 · No. 16

一次只做一件事的程序简单,却往往太慢。于是我们让软件去同时兼顾很多任务,或者让它们在很多个 core 上一起跑。速度就是从这里来的——而一整类古怪的 bug 也是从这里来的,它们都扎根于同一件事:两份工作在同一时刻碰了同一份数据。

只讲精髓 · 每个想法一个画面 · 掌握术语

§ 01

有两个词常被当成同一个意思来用,其实它们不是。一个讲的是来回兼顾,另一个讲的是手多。把它们分清楚,是后面一切的地基。

Concurrency 是来回兼顾;parallelism 是手多

一个厨师照看四道菜——切一会儿,再翻炒,再看看烤箱,飞快地切换——对比四个厨师在同一瞬间各做一道菜。

Concurrency 是靠在多个任务之间切换来应付它们——一个工人在好几件事上推进,从不真正在同一瞬间一起做。Parallelism 是很多任务真的在同一瞬间一起跑,在多个工人身上。一个厨师兼顾四道菜是 concurrent;四个厨师就是 parallel。它们常常合在一起,但它们是不同的想法——concurrency 是组织工作的方式,parallelism 是执行工作的方式。

一个 core 可以是 concurrent;多个 core 才是 parallel

一个售票员靠依次处理每个人的几个快步骤,就能服务一整条队;一排售票员则在同一秒里服务好几个人。

一个处理器 core 一次只做一件事,但它能在任务之间切换得飞快,以至于看起来像在同时做很多——这就是单个 core 上的 concurrency。真正的 parallelism 需要多个 core 实实在在地同时跑。现代机器有很多 core,所以真实的软件通常两样都做:好几个任务在推进中(concurrent),其中一些真的在一起跑(parallel)。知道自己手里是哪一种,就能解释什么样的提速根本上是可能的。

到底为什么要费这个劲

一个服务员如果接完一桌的全部点单、上完菜,然后才去招呼下一桌,就会让满屋子的人干等着、什么也没着落。

一次只做一件事会浪费掉大量的等待。当一个任务在等——等一个网络回复、一块磁盘、一个用户——的时候,这个工人本可以在另一件事上推进。Concurrency 把那段闲置的时间收回来;parallelism 则为繁重的工作加上纯粹的马力。两者合起来,就是一个应用在加载时还能保持响应的原因,也是一项大计算能用上每一个 core 的原因。代价是这门课其余部分要讲的那份复杂。

Concurrency 是靠切换来兼顾很多任务;parallelism 是在很多个 core 上让它们在同一瞬间一起跑。是不同的想法——一个组织工作,另一个执行工作。

§ 02

要让事情并发地跑,你得把工作拆成一条条独立的执行线。主要有两种,而它们之间的区别几乎只在于一件事:是否共享内存。

一个 process 是一个隔离的程序

两栋分开的楼里有两个分开的厨房——各有各的食材、各有各的台面,不通个电话就碰不到对方的库存。

一个 process 是一个正在运行的程序,有它自己的私有内存,与其他 process 隔离。两个 process 不会意外弄坏彼此的数据,因为它们什么都不共享——要通信,它们必须刻意地传递消息。那份隔离让 process 既安全又稳健,但也更重:启动一个代价更高,彼此之间说话也更慢。把 process 想成一个个分开的、筑了墙的程序。

一个 thread 与它的兄弟们共享内存

同一个厨房里好几个厨师,共用同样的台面和食材——协调起来很快,但他们可能在同一瞬间去抓同一把刀。

一个 thread 是一个 process 内部 的一条执行线,而同一个 process 里的多个 thread 共享同一份内存。这让它们既轻量、协调起来又快——但这也几乎是每一个 concurrency bug 的根源,因为两个 thread 可能在同一时间碰同一份数据。共享内存是一种威力,也是一种危险:通信便宜,碰撞危险。这门课的大部分,讲的都是这份危险。

在任务之间切换是有代价的

一个兼顾着多份活儿的工人,得先放下一份、准确记住自己刚才到哪儿了,再拿起另一份——而这一放一拿全都要花实打实的时间。

当一个 core 从一个任务切到另一个任务时,它会做一次 context switch:存下它刚才在哪儿,再载入下一个任务的状态。这很快,但不是免费的,而且做得太频繁——成千上万个小任务在猛烈地来回折腾——会把时间浪费在纯粹的开销上。这就是为什么「再多开点 thread」并不会自动变快:过了某个点,兼顾本身花的,比它省下的还多。

Process 是隔离的,有私有内存、传消息安全但重。Thread 共享内存——轻量又快,也是大多数 concurrency bug 的根源。

§ 03

很多时候,一个程序并不在计算——它在等。你怎么处理那段等待,是 blocking 还是异步,决定了一个工人能保持忙碌,还是干坐着闲置。

一个 blocking 调用停下来干等

站在柜台前,店员去后面取你的货,在他回来之前你什么也不做——你就那么站着,谁也帮不了。

一个 blocking 调用,是工人停下来等结果、在那之前什么也不做——问一句数据库,然后就那么站着,直到它回话。被阻塞期间,这个工人一事无成。对一个又快又小的步骤来说这没问题;但如果你在慢的东西上阻塞——网络、磁盘、别的服务——你浪费掉的,正是你本可以花在别的工作上的那段时间。

Async 让一个工人保持忙碌

在繁忙的熟食店取个号:与其僵站着,你去办别的事,等叫到你的号再回来。

异步(非阻塞)的工作把它反了过来:你启动一个慢操作,然后不去等,而是去做别的事,等结果就绪了再把它取回来。许多语言里的关键字 asyncawait 表达的正是这个——「启动这个,让我接着往下走;它做完了再回来。」一个工人可以同时让上百个慢操作在推进中,保持忙碌而不是闲置。一个 thread 就是这样服务成千上万条等待中的连接的。

Event loop 把这些等待都兼顾起来

一位极有条理的前台,接起每一通电话,把那些慢查询发出去,再接着处理谁先就绪——从不在任何一通上卡死。

Async 通常跑在一个 event loop 上:一个工人启动任务,每当一件慢事做完,就跑那一小段等着它的代码,然后接着往下。它感觉像很多事在一起发生,但其实是一个工人在就绪的任务之间飞快切换。这就是 JavaScript 和 Node 这类环境背后的模型——在单个 thread 上,把兼顾等待这件事做得快得惊人。

I/O-bound 喜欢 async;CPU-bound 需要 parallelism

一份大多在等送货的活,靠一个更好的兼顾者就能帮上忙;一份大多在搬重物的活,需要更多肌肉,而不是更好的调度。

合适的工具取决于你的工作在等什么。I/O-bound 的工作——大多在等网络、磁盘或别的服务——最适合 async:一个工人把所有等待都兼顾起来。CPU-bound 的工作——把一个 core 一直占满的繁重计算——从 async 那里得不到任何帮助(根本没有闲置时间可收回),它需要的是跨多个 core 的 parallelism。判断错了自己手里是哪一种,就是有些「提速」毫无作用的原因。

Blocking 停下来干等;async 启动那件慢事然后保持忙碌。等待靠 async 取胜(I/O-bound);繁重计算靠真正的 parallelism 取胜(CPU-bound)。

§ 04

这里是 concurrency 为什么难的核心所在。一旦两件事可能在同一时间碰同一份数据,你就可能拿到一个时有时无、在测试里看不见、复现起来叫人发狂的 bug。

一个 race condition:两只手,一件东西

两个人在同一瞬间去拿最后一块饼干——两人都看见它在那儿,两人都伸手抓,接下来发生什么,取决于那一刹那精确到毫厘的时机。

一个 race condition,是结果取决于那些碰着共享数据的并发操作之间精确的时机。两个 thread 都把一个计数器读成 5,都加一,都写回 6——于是一次计数被悄无声息地丢了,因为正确答案本该是 7。没有谁犯了逻辑错误;只是那些步骤交织得很糟。这是 concurrency 的标志性 bug,它直接来自共享状态加上同时访问。

可怕之处在于它时有时无

车里有个异响,你一把它开到修车铺它就消失了——真实存在、时有时无,又没法叫它现场表演。

Race condition 之所以棘手,是因为它们不确定:它们取决于时机,所以一千次里才出现一次,在你的测试里从不出现,只在生产环境的真实负载下才冒出来。同样的输入,可能成、也可能败,就看那微观尺度上的调度。这就是为什么 concurrency bug 有着可怕的名声——你没法可靠地复现它们,而「在我机器上是好的」恰恰是你该预料到的——哪怕它其实是坏的。

危险在于那份共享的、可变的状态

碰撞只会发生在两个人都被允许去拿、去改的东西上——把那个共享的橱柜锁上,或者给每人一个自己的,争抢就停了。

留意那个精确的根源:既共享(不止一个任务能够到它)又可变(它能被改)的数据。去掉任何一个属性,race 就消失了——只有一个任务碰的数据是安全的,谁都不改的数据可以随意共享。这是这门课其余部分赖以建立的关键洞见:每一种修法,本质上都是去控制、限制或避免共享的可变状态的一种办法。

一个 race condition 是两个任务在同一时间碰着共享的、可变的数据,而结果悬在时机上。它时有时无、在测试里看不见——而且是 concurrency 的标志性 bug。

§ 05

对一个 race 的经典修法,是确保同一时间只有一个任务碰那份共享数据。这有用——但你用来强制它的那个工具,自己带来了一个出名的危险。

一个 lock 给一个任务独占的访问

一间单人厕所的唯一一把钥匙:谁拿着谁进去;其他人都在门外等自己的轮次。任何时候里面绝不会有两个人。

一个 lock(或叫 mutex,取自「mutual exclusion」互斥)确保同一时间只有一个任务进入某一段代码。在碰那份共享数据之前,一个任务必须先拿到这个 lock;别的想要它的任务,就等它被释放。被保护的那一段叫 critical section(临界区)。做对了,这就把一场围着共享数据的混乱混战,变成一条有序的、一次一个的队列,race condition 就消失了。

Deadlock:所有人永远地等下去

两个人在一道窄门口,各自都不肯退后、非等对方先退——两人都卡住了,客客气气地,永永远远地。

Lock 带来了它们自己的经典故障:deadlock(死锁)。任务 A 拿着 lock 1 想要 lock 2;任务 B 拿着 lock 2 想要 lock 1。各自都在等对方松手,而谁也不会松——两个都永远冻在那里。它来自以不同的顺序去拿多个 lock。常见的解药是纪律:永远以同一个约定好的顺序去拿 lock,那个环形的等待就形不成了。

Lock 是正确的,但代价不小

一把厕所钥匙能维持秩序,但要是所有人都时时刻刻需要它,就会排起队,整个办公室都被拖慢到那一扇门的速度。

Lock 能修好 race,但它们有代价:当一个任务拿着 lock 时,别的任务都在等,所以重度加锁会把你一开始想从 concurrency 那里得到的速度抹掉。过度加锁会造成 contention(争用)——所有人都排在同一个 lock 上。所以你尽可能短地持有 lock,尽可能少地去保护,并偏向那些需要更少 lock 的设计。一个正确、但被串行化在一个 lock 上的程序,并不比一个单线程的好多少。

一个 lock 一次给一个任务独占的访问,杀掉了 race。但 lock 可能 deadlock,而过度加锁会把一切串行化——正确,但慢。

§ 06

Lock 对付的是共享可变状态的症状。更深一层的做法,是把设计做成根本没那么多东西要去对付——而有几个模式,能让一整类 concurrency bug 干脆变得不可能。

别共享内存——传消息

与其让许多厨师围着一个共享的台面争抢,不如每人在自己的工位上干活,把做好的盘子顺着传菜口递下去——没有两个人会去够同一样东西。

共享内存的一个强有力的替代,是 message passing(消息传递):任务们不碰同一份数据;它们通过一个 queue(队列)互相发送消息。某一份数据只归一个任务所有,别人想要改动就发一条消息来请求。它有一句出名的口号:「don't communicate by sharing memory; share memory by communicating(不要靠共享内存来通信;要靠通信来共享内存)。」去掉那份共享的访问,你就从根上去掉了 race,不需要任何 lock。

Immutable 的数据没法被 race

给每个人发一份他们自己的复印件,而不是一份共享的原件——他们可以一起读,谁也没法在另一个人正读着的东西上乱涂。

如果数据一经创建就永不改变——如果它是 immutable(不可变)的——那么任意多个任务可以在同一时间读它,零风险,因为危险从来不是共享,而是共享 又改动。与其原地改一个值,你去造一个新的。Immutability 去掉了「共享且可变」这个条件的一半,连带去掉了一整类 bug。这就是函数式风格如此倚重它的原因。

Atomic 操作是不可分的

一道旋转闸,每过一个人就以一次不可分的「咔哒」计数——不存在一个数到一半的时刻,让两个人溜过去。

有些操作是 atomic(原子)的:它们作为一个不可分的步骤发生,没法在中途被打断,所以两个任务没法在彼此更新到一半时撞上。语言和数据库提供 atomic 计数器、atomic 交换,以及事务,正是为了那些 race 最爱的、小小的共享更新。当你确实必须共享一个会变的值时,一个 atomic 操作往往是比一整把 lock 更便宜、更安全的修法——那个更新根本没法被拆开。

最好的 concurrency bug,是那个被弄得不可能发生的:传消息而不是共享、把数据做成 immutable、或者用 atomic 操作——race 就无处容身了。

§ 07

Concurrency 是一种你该有意去加的威力,而不是条件反射。本事在于,只在问题需要它的时候才去伸手,并选择那个能把活干成的最简单的模型。

让工具对上瓶颈

对一份全是等待的活,你不会去雇更多搬运工;对一份全是搬重物的活,你也不会去找一个更好的调度员——你让帮手对上时间真正花在哪儿。

在加 concurrency 之前,先弄清你的 bottleneck(瓶颈)。如果工作是 I/O-bound——大多在等——单个 thread 上的 async 往往就能简单地解决它。如果是 CPU-bound——大多在算——你需要跨多个 core 的真正 parallelism。如果两者都不是——又快又简单——那加 concurrency 只会让你白白买来一堆 bug、毫无收益。最常见的错误,是在朴素的顺序代码本来就已经够快的时候,还伸手去拿 thread。

把共享的表面积保持得极小

一间作坊,每个人大多独自干活,只有一个小小的、标记清楚的共享工具架——共享得越少,能出错的就越少。

既然每一个 concurrency bug 都来自共享的可变状态,那个最高明的做法就是把共享降到最小。让大多数数据只归一个任务所有,能做成 immutable 的就做成 immutable,把那些真正共享、会变的部分,圈进一个小小的、被仔细守护的表面里。一个共享表面极小的设计,是一个你真能推理清楚的设计;一个什么都共享、什么都可变的设计,是一座闹鬼的宅子。这里的简单不是可选项——它就是全部的防线。

在你加 concurrency 之前
  • 瓶颈是什么——I/O-bound(async)、CPU-bound(parallel),还是都不是(别加)? - 哪份数据是共享且可变的,两个任务有没有可能在同一时间碰它? - 我能不能避开 共享——message passing、immutability,或者一个 atomic 操作? - 如果我必须加锁,那个 critical section 是不是极小、lock 的顺序是不是一致? - 它会不会 deadlock,我有没有排除掉 那个环形的等待? - 加上去的这份复杂值不值,还是说顺序代码本来就已经够 快了?
你如今掌握了的词
  • concurrency / parallelism——靠切换来兼顾,对比在同一瞬间一起跑。 - process / thread——隔离、带私有内存,对比共享内存(以及那份风险)。 - blocking / async / await / event loop——停下来干等,对比启动后接着往下。 - I/O-bound / CPU-bound——偏重等待的,对比偏重计算的工作。 - race condition—— 围着共享的、可变的状态、取决于时机的 bug。 - lock / mutex / critical section / deadlock / contention——独占的访问及其危险。 - message passing / immutable / atomic——那些 从根上去掉 race 的模式。
你把它用好的迹象
  • 你为等待伸手去拿 async、为计算伸手去拿 parallelism——而不是条件反射。 - 你能 准确指出哪份数据是共享且可变的,而且它很小。 - 你偏向 message passing 和 immutability,而不是到处撒 lock。 - 你的 lock 是短的、有序的, 而且你对 deadlock 推理过。
  • 在朴素的顺序代码本来就已经够快的地方,你不去加 concurrency。

Concurrency 是一种有意为之的威力:等待靠 async,计算靠 parallelism,再加上一个被有意守护的、极小的共享表面。每一个 bug 都追溯到共享的可变状态——所以共享得越少越好。

速成课到此结束 · 7 章 · 掌握术语

接下来是练习:拿一个等得很多的东西——一个一个接一个地发很多网络调用的脚本——把那些调用改成 async,让它们一起跑。看着它在原来零头的时间里就跑完。然后找一个共享的计数器或列表,推理一下如果两个任务在同一时间撞上它会发生什么。当你感受到那份提速、并发现那个 race 的那一刻,这些想法就变具体了。但有一个想法要高过其余:同时做很多事是软件保持快的方式,而每一处难点都追溯到同一个根——两件事在同一时间碰了同一份数据。把那个控制住,你就驯服了 concurrency。