fedorthinks

速成课 · No. 16

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

19 个概念 · 每个配一张图 · 结尾有速查表

§ 01

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

地图:六组共 19 个概念

一张地铁线路图:先看到整条线路,再一站一站地坐过去。

先是基础——什么是 concurrency、parallelism 和 CPU core。然后是五组,每章一组:thread 与 process、等待(blocking、async、event loop)、共享状态与 race、lock 及其风险,以及从根上消除 race 的更安全的模式。

BasicsConcurrencyParallelismCPU coreThreads & processesProcessThreadContext switchWaitingBlocking callAsync / awaitEvent loopI/O- vs CPU-boundShared stateRace conditionNon-determinismShared mutable stateLocksLock / mutexDeadlockContentionSafer patternsMessage passingImmutabilityAtomic operation
六组概念——第 1–6 章依次讲它们。第 7 章把一切串起来。

每个概念怎么读

一本护照:每个人都是同样的栏目,所以很容易比较。

下面每个概念都用同样的方式讲:它的英文名称(记住它——这是你查文档要用的词)、一幅生活中的画面、一张图,以及一张带有这些栏目的卡片:问题、思路、例子、代价和关联概念。结尾是一张关联图和一页速查表。

Concurrency — 来回兼顾多个任务

一个厨师照看四道菜——切一会儿,再翻炒,再看看烤箱,在它们之间飞快地切换。

Concurrency 是靠在多个任务之间来回切换来应付它们——一个工人在好几件事上都有进展,但绝不会在同一瞬间做两件事。一个厨师同时兼顾四道菜,就是 concurrency。它是一种组织工作的方式。

WorkerABCDACBDABCDtime4 tasks · 1 worker · fast switchingprogress on all four — but never two at the same instant
一个工人,四个任务:它飞快切换,每一个都在往前走——但任何一瞬间只有一个在运行。
ConceptConcurrency· 靠切换来兼顾
问题
任务很多,工人只有一个——而且工人会在等待中浪费时间。
思路
在任务之间切换,让每一个都有进展。
例子
一个厨师照看四道菜,轮流各做一步。
代价
复杂度:任务彼此交错,步骤的顺序变得不可预测。
关联

常与 Parallelism 搭配,但是另一个概念。是 Async 和 Event Loop 的基础。

Parallelism — 手多

四个厨师,在同一瞬间各做自己的一道菜。

Parallelism 是多个任务在多个工人上真正在同一瞬间运行。四个厨师就是 parallelism。它是一种执行工作的方式。Concurrency 和 parallelism 常常搭配,但它们是不同的想法:前者讲你怎么安排工作,后者讲有多少只手在干。

Core 1Task ACore 2Task BCore 3Task CCore 4Task Dsame instant4 tasksreally runtogethertime
四个 core,四个任务——在同一瞬间,四个都真的在运行。
ConceptParallelism· 在同一瞬间运行
问题
活儿多到一个工人不够,哪怕它从不等待。
思路
在多个工人(core)上于同一瞬间运行任务。
例子
一个大计算被切成几块,用上机器上的每一个 core。
代价
需要多个 core;它们之间共享的数据会变得危险。
关联

CPU-bound 工作的解药。建立在 CPU Core、Thread 和 Process 之上。

CPU Core — 一个 core 是 concurrent,多个 core 是 parallel

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

处理器的一个 core 一次只做一件事,但能在任务之间切换得极快,看起来像在做很多事——这就是单个 core 上的 concurrency。真正的 parallelism 需要多个 core 真的同时在跑。现代机器有很多 core,所以真实的软件通常两者兼有:好几个任务在进行(concurrent),其中一些真的在一起运行(parallel)。

Core 1ABABABCore 2CDCDCDtimeswitching inside one core = concurrencytwo cores running at once = parallelism
每个 core 内部,任务轮流上(concurrency);两个 core 同时运行(parallelism)。
TermCPU Core· 处理器核心
是什么
处理器里的一个执行单元:任何一瞬间它只做一件事。
原理
在任务之间切换得非常快——所以看起来像在做很多事。
例子
一台 8 个 core 的机器,真的能在同一瞬间做 8 件事。
为什么重要
core 的数量决定了哪些提速根本有可能。
关联

一个 core 给出 Concurrency,多个 core 给出 Parallelism。切换要付出一次 Context Switch 的代价。

为什么要费这个劲

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

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

one at a timeAA waits (network)BB waits (disk)done (late)overlapped (concurrent)AA waitsBB waitsdone (sooner)while A waits, the worker already starts B
一个接一个:工人在等待时闲着。重叠起来:A 在等的时候,B 已经在跑——一切都更早完成。

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

§ 02

要让事情 concurrent 地运行,你得把工作拆成各自独立的执行线。主要有两种,而它们的区别主要就在一件事上:是否共享内存。

Process — 隔离的程序

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

Process 是一个正在运行的程序,拥有自己的私有内存,与其他 process 隔离。两个 process 不会不小心弄坏对方的数据,因为它们根本不共享任何数据——要通信,就得有意地互相传消息。这种隔离让 process 安全而稳健,但更重:启动一个花费更多,它们之间说话也更慢。

Process APrivate memoryits own thread(s)Process BPrivate memoryits own thread(s)messagereplynothing shared → safe, but heavier to start and to talk
每个 process 有自己的内存。它们只通过消息交谈——安全,但更贵。
ConceptProcess· 隔离的程序
问题
一个程序可能不小心弄坏另一个程序的数据。
思路
给每个程序一块自己的、用墙隔开的私有内存。
例子
机器上运行的两个程序;它们只通过消息交谈。
代价
更重:启动一个花费更多,process 之间通信也更慢。
关联

Thread 住在 process 里面。Process 之间互相交谈就是 Message Passing。

Thread — 与同伴共享内存

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

Thread 是 process 内部的一条执行线,同一个 process 里的 thread 共享同一块内存。这让它们轻巧、协调起来很快——但也是几乎所有 concurrency bug 的来源,因为两个 thread 可能在同一时间碰同一份数据。共享内存既是力量也是隐患:通信便宜,碰撞危险。

One processThread 1Thread 2Thread 3Shared memorythe same data for every threadcheap to coordinate · can collide on the same data
同一个 process 里的三个 thread,都看着同一块共享内存。
ConceptThread· 执行线
问题
Process 很重,而且同一个程序的各部分需要紧密地共享数据。
思路
在一个 process 内有多条执行线,共享内存。
例子
一个厨房里好几个厨师,用同样的台面。
代价
两个 thread 可能同时碰同一份数据——几乎每一个 race 的根源。
关联

Thread 的共享内存就是 Shared Mutable State——于是有了 Race Condition 和 Lock。

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

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

当一个 core 从一个任务切换到另一个任务时,它会做一次 context switch:保存自己做到哪儿了,再加载下一个任务的状态。它很快,但不是免费的;切得太频繁——成千上万个小任务来回折腾——就会把时间浪费在纯粹的开销上。这就是为什么“多开几个 thread 就行”不会自动变快。

a few switches — fineTask AswitchTask BswitchTask Athrashing — thousands of tiny tasksAswBswCswDswEswFswGswHswIswJswsave state · load state — past a point the juggling costs more than it saves
几次切换不算什么。成千上万个小任务——时间就花在切换本身上了。
TermContext Switch· 任务切换开销
是什么
core 保存一个任务的状态,再加载另一个任务的状态。
例子
一个 core 在任务 A 和 B 之间交替,中间有一小段“保存并加载”的停顿。
陷阱
任务太多时,兼顾本身的花费比它省下的还多(“thrashing”)。
要点
“更多 thread”不会自动等于“更快”。
关联
Concurrency 在单个 CPU Core 上的代价;随 Thread 数量增长。

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

§ 03

很多时候,程序并不是在计算——而是在等待。你怎么处理这种等待,是阻塞还是异步,决定了一个工人能一直忙着,还是闲坐着。

Blocking Call — 停下来干等

站在柜台前,店员去后面取你的东西,你什么也不做,直到他回来——你站在那儿的时候,没法帮其他任何人。

Blocking 调用是指工人先停下来等结果,然后才做别的事——问一下数据库,就站在那儿等它回答。被阻塞的时候,这个工人什么也完不成。对单个很快的步骤,这没问题;但如果你在慢的东西上阻塞——网络、磁盘、其他服务——你就浪费了本可以用在别的活儿上的那段时间。

Workerasks and standsDatabaseslow answerqueryanswer (much later)workblocked — doing nothingworkthe worker stands still until the answer comes back
工人发出一个查询,然后闲站着,直到答案回来。
TermBlocking Call· 停下来等
是什么
工人停下来等结果的一种调用。
例子
问数据库——然后站在那儿等它回答。
何时没问题
单个很快的步骤:几乎没什么可等的。
代价
遇到慢的东西(网络、磁盘、其他服务),工人就在浪费自己的时间。
关联
Async / Await 的反面。

Async / Await — 一个工人始终忙着

在繁忙的熟食店里领一个号:你不用呆站着,而是先去办别的事,等叫到你的号再回来。

异步(non-blocking)的做法正相反:你启动一个慢操作,不去等,而是去做别的事,等结果好了再来取。许多语言里的关键字 async 和 await 表达的正是这个——“开始这件事,让我继续;等它完成再回来找我。”一个工人可以同时有成百上千个慢操作在进行。一个 thread 就是这样服务成千上万个等待中的连接的。

Worker (one thread)start Astart Bstart Cother workA ✓C ✓B ✓await A — in flightawait B — in flightawait C — in flightstart the slow call, keep working, pick up the result when readyawait = “come back here when it finishes”
工人启动 A、B 和 C,去做别的活儿,每个结果好了就来取。
ConceptAsync / Await· 异步,non-blocking
问题
阻塞的工人在等慢的东西时闲坐着。
思路
启动慢的事,不等——继续干;好了再回来。
例子
一个发出很多网络调用的脚本:用了 async,它们一起跑,而不是一个接一个。
代价
代码更难读;只在有等待的地方才有帮助。
何时使用
I/O-bound 工作:网络、磁盘、其他服务。
关联
通常跑在 Event Loop 上。是 Blocking Call 的反面。

Event Loop — 兼顾所有的等待

一位极有条理的前台,接每一通电话,启动那些慢的查询,再处理下一个已经就绪的人——从不被任何一件事卡住。

Async 通常跑在 event loop 上:一个工人去启动任务,每当有慢的事完成,就运行等着它的那一小段代码,然后继续往前。感觉像很多事同时在发生,其实是一个工人在就绪的任务之间飞快切换。这就是 JavaScript 和 Node 这类环境背后的模型——在单个 thread 上兼顾等待,效率高得惊人。

Ready queuereply arrivedtimer firedfile readEvent loopone threadSlow I/Onetwork · disknextstartrepeatwhen done, its callback joins the queue
Loop 取下一个就绪的任务,启动慢的工作,然后重复。完成的回复加入队列。
ConceptEvent Loop· 单线程调度器
是什么
一个 thread,不断地取下一个就绪的任务,周而复始。
原理
启动慢的事;它完成时,运行等着它的那一小段代码。
例子
JavaScript 和 Node:在单个 thread 上应付成千上万个等待。
陷阱
一个繁重的计算会占住这唯一的 thread——其他人都得等。
关联
Async / Await 背后的引擎;最适合 I/O-bound 工作。

I/O-bound vs CPU-bound — 你的工作在等什么

一份主要在等送货的活儿,需要的是更好的统筹者;一份主要是搬重物的活儿,需要的是更多肌肉,而不是更好的调度。

该用什么工具,取决于你的工作在等什么。I/O-bound 工作——主要在等网络、磁盘或其他服务——非常适合 async:一个工人兼顾所有的等待。CPU-bound 工作——让一个 core 始终满载的繁重计算——从 async 得不到任何帮助(没有闲置时间可夺回),而需要跨 core 的 parallelism。判断错是哪一种,就是某些“提速”毫无作用的原因。

I/O-boundcpuwaitcpuwaitcpuAsyncjuggle the waitingCPU-boundbusy computingParallelismmore coresasync can't help work that never waits
左:工作主要在等——async 能救它。右:core 始终很忙——你需要更多 core。
TermI/O-bound vs CPU-bound· 等待多 vs 计算多
I/O-bound
主要在等网络、磁盘、其他服务 → async。
CPU-bound
繁重计算让 core 忙个不停 → 跨 core 的 parallelism。
例子
很多网络调用——I/O-bound。在每个 core 上做大计算——CPU-bound。
陷阱
搞混了,“提速”就毫无作用。
关联
在 Async / Await 与 Parallelism 之间做选择。

Blocking 是停下来干等;async 是启动慢的事并保持忙碌。Async 赢在等待(I/O-bound);真正的 parallelism 赢在繁重计算(CPU-bound)。

§ 04

这里就是 concurrency 为什么难的核心。只要两样东西能在同一时间碰同一份数据,你就可能遇到一个时有时无、测试里看不见、复现起来让人抓狂的 bug。

Race Condition — 两只手,一个东西

两个人在同一瞬间伸手去拿最后一块饼干——两人都看到它在那儿,两人都去抓,接下来会怎样,取决于那一刹那的精确时机。

Race condition 是指结果取决于多个并发操作碰共享数据时的精确时机。两个 thread 都把计数器读成 5,都加一,都写入 6——于是一次计数悄悄丢了,因为正确答案是 7。没有人犯逻辑错误;只是步骤交错得不巧。这是 concurrency 的标志性 bug。

Thread AcounterThread Bread 5read 5write 5 + 1 = 6write 5 + 1 = 65566expected 7 — got 6: one update silently lostno logic mistake — the steps just interleaved badly
两个 thread 都读到 5,都写入 6。我们期望 7——一次更新悄悄丢了。
TermRace Condition· 取决于时机的 bug
是什么
结果取决于任务在共享数据上的时机。
例子
两个 thread 都把计数器读成 5,都写入 6——而正确答案是 7。
原因
共享且可变的数据,加上同时访问。
陷阱
没有逻辑错误——只是步骤以糟糕的顺序交错了。
关联

由 Shared Mutable State 生出。用 Lock、Message Passing、Immutability、Atomic Operation 来治。

Non-determinism — 可怕之处在于它时有时无

汽车里的一阵异响,一拿去给修理工看就消失了——真实、时有时无,而且没法按需展示给人看。

Race condition 之所以讨厌,是因为它们是 non-deterministic:取决于时机,所以一千次运行里才出现一次,在你的测试里从不出现,只在生产环境的真实负载下才冒头。同样的输入,可能成功也可能失败,取决于微观层面的调度。这就是 concurrency bug 名声可怕的原因——你没法稳定地复现它们,而“在我机器上是好的”正是你在它出了问题时也会预料到的结果。

same code · same input · 60 runs✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✗✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓one run fails — it depends on micro-timing, and rarely shows up in tests
同样的代码,同样的输入:几乎每次运行都通过,只有一次失败——因为时机。
TermNon-determinism· 取决于时机的结果
是什么
同样的输入,在不同次运行中给出不同的结果。
为什么
结果取决于任务在微观层面的调度。
例子
一个 bug 一千次运行才出现一次——测试里从不出现,只在生产环境的负载下出现。
陷阱
“在我机器上是好的”正是你会预料到的,哪怕它已经坏了。
关联

让 Race Condition 如此危险的原因。这就是为什么让 bug 不可能发生,比去追捕它们更好。

Shared Mutable State — 真正的危险

碰撞只发生在两个人都能抓取并改动的东西上——把共享的柜子锁上,或者给每人各一个,争吵就停了。

注意这个精确的来源:既共享(不止一个任务能够到它)又可变(它能被改动)的数据。去掉其中任何一个性质,race 就消失了——只有一个任务碰的数据是安全的,没人改动的数据可以放心地随便共享。这是这门课其余部分所依托的关键洞见:每一种修复,其实都是一种控制、限制或避免共享可变状态的方式。

not sharedsharedimmutablemutablesafeprivate and frozensaferead freelysafeowned by one taskRACEthe danger zoneremove either property and the race vanishes
四个格子里只有一个是危险的:既共享又可变的数据。
ConceptShared Mutable State· 共享 + 可改动的数据
是什么
多个任务都够得到、并且能被改动的数据。
思路
去掉“共享”或“可变”——race 就无处栖身。
例子

两个 thread 都往里写的共享计数器或列表——危险。只归一个任务所有的数据,或不可变的数据——安全。

要点
这门课里的每一种修复,都是在控制、限制或去除这种状态。
关联

Race Condition 的来源。Lock 控制它,Message Passing 去掉“共享”,Immutability 去掉“可变”。

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

§ 05

治 race 的经典办法,是确保同一时间只有一个任务碰共享数据。它管用——但用来执行这一点的工具,自己也带来一个出了名的隐患。

Lock / Mutex — 让一个任务独占访问

一间单人卫生间只有一把钥匙:谁拿着钥匙谁进去;其他人都在外面排队等。里面永远不会同时有两个人。

Lock(或叫 mutex,即“mutual exclusion”互斥)确保同一时间只有一个任务进入某一段代码。碰共享数据之前,任务必须先获取 lock;其他想要的任务要等到它被释放。被保护的那一段叫 critical section。用对了,它就把围绕共享数据的混乱抢夺,变成有序的一次一个的队伍,race condition 也就消失了。

critical sectionThread Aholds the lockThread BwaitsLockShared datacounterblockedacquire → work → release: one task at a time
Thread A 拿着 lock,在 critical section 里干活。Thread B 在外面等。
ToolLock / Mutex· 互斥
问题
两个任务同时改共享数据——一个 race。
思路
获取 lock → 在 critical section 里干活 → 释放。其他人都等着。
例子
一个 thread 拿到 lock,把计数器加一,然后释放它。
代价
其他任务要等;有 Deadlock 和 Contention 的风险。
何时使用
当共享的可变状态确实无法避免时。
关联

控制 Shared Mutable State。往往更便宜的做法:Atomic Operation 或 Message Passing。

Deadlock — 所有人永远在等

两个人在一个窄门口,各自都不肯先退一步,非要对方先退——两人都被卡住了,客客气气地,永远如此。

Lock 带来了它自己的经典故障:deadlock。任务 A 拿着 lock 1,想要 lock 2;任务 B 拿着 lock 2,想要 lock 1。各自都在等对方放手,而谁也不会放——两个都永远冻住了。它来自以不同顺序获取多个 lock。常见的解法是纪律:总是按同一个约定好的顺序获取 lock,这样 circular wait(循环等待)就形不成。

Task ALock 1Task BLock 2holdsholdswantswantscircular waitfix: always take locks in the same order (1, then 2)
A 拿着 1 想要 2,B 拿着 2 想要 1——圈闭合了,两个都永远等下去。
TermDeadlock· 循环等待
是什么
任务们围成一圈互相等待,谁也没法继续。
例子
A 拿着 lock 1 想要 2;B 拿着 2 想要 1。
原因
以不同顺序获取了多个 lock。
解法
总是按同一个约定好的顺序拿 lock。
关联
Lock / Mutex 的经典隐患。

Lock Contention — lock 是正确的,但有代价

一把卫生间钥匙能维持秩序,但如果人人都不断需要它,就会排起队,整个办公室都慢到那一扇门的速度。

Lock 能修好 race,但有代价:一个任务拿着 lock 的时候,其他任务都得等,所以锁得太多,会把你一开始想从 concurrency 里得到的速度抹掉。过度加锁会造成 contention——所有人都在同一个 lock 上排队。所以你要尽可能短地持有 lock,尽可能少地保护东西,并偏好需要更少 lock 的设计。一个正确、但被串行化在一个 lock 上的程序,并不比单 thread 的好多少。

waiting in lineT5T4T3T2LockT1insideheavy locking serialises the work — correct, but slowhold locks briefly · protect as little as possible
四个 thread 为一个 lock 排队——工作一次只通过一个。
TermLock Contention· 在 lock 上排队
是什么
很多任务为同一个 lock 排队。
例子
一个被串行化在一个 lock 上的程序,几乎等于单 thread。
代价
抹掉 concurrency 带来的收益:正确,但慢。
解法
持有 lock 的时间要短,保护的东西要尽量少,偏好需要更少 lock 的设计。
关联
Lock / Mutex 继 Deadlock 之后的第二个代价。

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

§ 06

Lock 对付的是共享可变状态的症状。更深一层的做法,是把设计做成要对付的东西更少——而少数几种模式,能让整整一类 concurrency bug 根本不可能出现。

Message Passing — 别共享内存,传消息

不是一群厨师抢着用同一个共享计数器,而是每个人在自己的工位上干活,把做好的盘子沿着出餐口传下去——没有两个人会去够同一样东西。

共享内存之外一个强大的替代方案是 message passing:任务不碰同一份数据;它们通过一个队列互相发消息。一份数据只归一个任务所有,其他任务通过发消息来请求改动。有一句著名的口号:“不要通过共享内存来通信;要通过通信来共享内存。”去掉共享访问,你就从根上去掉了 race,不需要任何 lock。

Task ATask BQueuemsgmsgmsgOwner taskonly it touches the datadon't communicate by sharing memory;share memory by communicating
任务把消息发进一个队列。只有拥有者任务碰数据。
PatternMessage Passing· 用通信代替共享
问题
共享内存需要 lock,还会滋生 race。
思路
一个任务拥有数据;其他任务通过队列发消息来请求改动。
例子
厨师们在各自的工位上,把盘子沿着出餐口传下去,而不是共用一个计数器。
代价
消息比直接访问内存慢。
何时使用
当每份数据都可以只有一个拥有者时。
关联

去掉 Shared Mutable State 里的“共享”。Process 之间也是这样交谈的。

Immutability — 不可变的数据不会被 race

给每个人发一份自己的复印件,而不是共用一份原件——大家可以同时阅读,而且没人能在别人正读的东西上乱涂乱画。

如果数据在创建之后永远不变——也就是 immutable——那么任意多个任务都可以同时读它,毫无风险,因为危险从来不在于共享,而在于共享并且改动。你不是原地改一个值,而是造一个新的。Immutability 去掉了“共享且可变”这个条件的一半,也就去掉了整整一类 bug。这就是函数式风格如此倚重它的原因。

Value v1never changesValue v2a new copyupdateReader 1Reader 2Reader 3many readers at once — nothing to race on
值 v1 永远不变——任意多个任务都可以读它。一次更新会生成一个新值 v2。
PatternImmutability· 不可变的数据
问题
有人在别人读共享数据的时候改了它。
思路
不要原地改一个值——造一个新的。
例子
每人拿到自己的复印件;没人能在别人的上面乱涂乱画。
代价
是新的副本,而不是原地编辑。
何时使用
被很多任务读取的数据。函数式风格的支柱。
关联
去掉 Shared Mutable State 里的“可变”。

Atomic Operation — 不可分割的更新

一个旋转闸机,在每个人通过时以一次不可分割的咔嗒声计数——没有“数到一半”的瞬间能让两个人溜过去。

有些操作是 atomic 的:它们作为一个不可分割的步骤发生,不能在半途被打断,所以两个任务不会在更新中途撞见对方。语言和数据库提供了 atomic counter、atomic swap 和 transaction,正是为了那些 race 最爱找上的小型共享更新。当你确实必须共享一个会变的值时,atomic 操作往往是比完整 lock 更便宜、更安全的修复。

not atomic: three stepsreadadd 1writeanother task can slip in between the stepsatomic: one stepincrement — one indivisible stepatomic counters · atomic swaps · transactions
“读、加、写”是三步,别的任务可以在中间插进来。Atomic 自增是一步。
PatternAtomic Operation· 不可分割的一步
问题
一个多步骤的更新可能在半途被打断。
思路
把更新做成一个不可分割的步骤。
例子
一个 atomic counter、一次 atomic swap、一个数据库 transaction。
代价
只适合小型更新,不适合大段逻辑。
何时使用
一个共享的、会变的值,比如计数器。
关联
往往比 Lock / Mutex 更便宜、更安全;能修复 Race Condition。

最好的 concurrency bug,是被做成不可能发生的那个:传消息而不是共享,让数据不可变,或者用 atomic 操作——race 就无处栖身。

§ 07

Concurrency 是一种你该有意添加的力量,而不是出于条件反射。真正的功夫,是只在问题需要时才去用它,并选择能把活儿干完的最简单的模型。

让工具匹配瓶颈

对一份全是等待的活儿,你不会多雇搬运工;对一份全是搬重物的活儿,你也不会换个更好的调度员——你让帮助对准时间真正花掉的地方。

在添加 concurrency 之前,先知道你的瓶颈。如果工作是 I/O-bound——主要在等——单个 thread 上的 async 往往就能简单地解决。如果是 CPU-bound——主要在算——你需要跨 core 的真正 parallelism。如果两者都不是——又快又简单——那加 concurrency 只会白白买来 bug。最常见的错误,是在普通的顺序代码本来就够快的时候,去伸手拿 thread。

Where does time go?AsyncI/O-bound: waitingParallelismCPU-bound: computingSequentialalready fast enoughmostly waitingcomputingneithermost common mistake: threads where plain code was enough
先问时间花在哪里。等待 → async,计算 → parallelism,都不是 → 保持原样。

让共享的面保持极小

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

既然每个 concurrency bug 都来自共享可变状态,最根本的做法就是把共享降到最小。让大部分数据归一个任务所有,能做成不可变的就做成不可变的,把真正共享、会变的部分限制在一个小而被严加看守的面上。共享面极小的设计,是你真正能推理清楚的;一切都共享且可变的设计,就是一座鬼屋。在这里,简单不是可选项——它就是全部的防线。

这些概念如何相连

一棵家谱树:所有的麻烦有同一个祖先,所有的解药有同一个目标。

这门课里几乎所有东西都围着一个点转。Thread 共享内存 → 这就造出了共享可变状态 → 它滋生出 race。四种解药——lock、message passing、immutability、atomic——来控制或消除它。Lock 带来了它自己的麻烦:deadlock 和 contention。另外还有一个按工作负载做的选择:等待 → async + event loop,计算 → parallelism。

Threadsshare memoryShared mutable stateRace conditioncausesLock / mutexMessage passingImmutabilityAtomic opsDeadlockContentionI/O-boundAsync + event loopCPU-boundParallelism (cores)every fix controls, limits or removes shared mutable state
这门课里各个概念之间的主要联系。
添加 concurrency 之前
  • 瓶颈是什么——I/O-bound(async)、CPU-bound(parallel),还是都不是(那就别加)?
  • 哪些数据是共享且可变的,两个任务有可能同时碰它吗?
  • 我能避免共享吗——message passing、immutability,还是一个 atomic 操作?
  • 如果非用 lock 不可,critical section 是不是极小,lock 的顺序是不是一致?
  • 会不会 deadlock,我是否已经排除了 circular wait?
  • 增加的复杂度值得吗,还是顺序代码本来就够快了?
说明你用得好的迹象
  • 你为等待用 async,为计算用 parallelism——而不是出于条件反射。
  • 你能准确指出哪些数据是共享且可变的,而且它们很少。
  • 你偏好 message passing 和 immutability,而不是到处撒 lock。
  • 你的 lock 是短的、有序的,而且你已经推理过 deadlock。
  • 在普通的顺序代码本来就够快的地方,你不加 concurrency。

Concurrency 是有意为之的力量:为等待用 async,为计算用 parallelism,并且有意地看守一个极小的共享面。每个 bug 都追溯到共享可变状态——所以尽量少共享。

速查表:全部 19 个概念

基础

Concurrency来回兼顾

一个工人靠切换来兼顾多个任务。一种组织工作的方式。

Parallelism手多

多个任务在多个 core 上于同一瞬间运行。一种执行工作的方式。

CPU Core处理器核心

一次只做一件事;一个 core 给出 concurrency,多个 core 给出 parallelism。

Thread 与 process

Process隔离的程序

有自己内存的隔离程序。安全,但重。

Thread执行线

process 内部的一条执行线;thread 共享内存。快,但有风险。

Context Switch切换开销

保存一个任务,加载另一个。不是免费的。

等待

Blocking Call停下来等

停下来等答案,什么也不做。

Async / Awaitnon-blocking

启动慢的事,继续干活,好了再回来。

Event Loop单线程调度器

一个 thread 不断地取下一个就绪的任务。

I/O-bound vs CPU-bound等待 vs 计算

在等 → async。在算 → parallelism。

共享状态

Race Condition时机 bug

结果取决于任务在共享数据上的时机。

Non-determinism时有时无

同样的输入,不同的结果。这种 bug 很少在测试里出现。

Shared Mutable State共享 + 可改动

每个 race 的来源。去掉“共享”或“可变”。

Lock

Lock / Mutex互斥

critical section 里一次只有一个任务。

Deadlock循环等待

任务围成一圈互相等待。解法:统一的 lock 顺序。

Lock Contention在 lock 上排队

在一个 lock 上排队,会抹掉速度。

更安全的模式

Message Passing用通信代替共享

数据有一个拥有者;其他任务给它发消息。

Immutability不可变的数据

数据永不改变——大家可以同时读它。

Atomic Operation不可分割的一步

一次不可分割的一步完成的更新。

速成课到此结束 · 7 章 · 19 个概念

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