第八章:单线程的堵车与多线程的狂欢(以及地狱)

欢迎回来,旅行者:还记得那个“一心一意”的美好年代吗?

欢迎回来,时间旅行者。在你沉睡之前,你最后操作的可能是那台亲切的 Commodore 641 或是 Apple II2。让我们花点时间,回忆一下那个纯真的年代。

你,你的 BASIC 解释器,还有那块谦逊的 6502 芯片。你们的关系是如此“纯洁”:一对一,忠诚,且具有绝对的排他性。

在你的时代,“程序”就是宇宙的中心。这就是我们今天要讨论的第一个概念,你所熟知的世界:单线程(Single-Threading)。

当你的程序开始运行时,它会完全占有整个 CPU2。从你输入 $POKE 53281, 0 (改变C64的屏幕边框颜色)的那一刻起,到程序执行 $END$,CPU 就是你最忠实的仆人。它有且仅有你这一个主人,它的注意力百分之百地集中在你赋予它的任务上2

这是一种美妙的、线性的、完全可预测的执行方式3。你的代码从第10行跑到第20行,CPU 绝不会中途开小差,自作主张地跑去执行第500行的代码(除非你用 $GOTO$ 命令它这么做,但那依然是你的意志)。

我们不妨用一个你(沉睡前)也能理解的类比:一个人的小店。

在80年代,编程就像经营一家只有一个员工(CPU)的小店4。这个员工身兼数职:他负责收银(计算),负责上货(从内存读取),还要负责拖地(处理输入/输出)。

这种模式有一个巨大的、无可比拟的优点:零沟通成本。

你(程序员)作为老板,不需要给这个员工开会。员工(CPU)不需要和任何人“同步”工作。他永远不会“误解”你的指令,或者把两份顾客的订单搞混。他是一个“思想纯粹”的模范工人,一次只做一件事,做完再做下一件3

但这种幸福,也伴随着一个唯一的(且致命的)缺点:堵塞(Blocking)。

当这个唯一的员工跑去仓库找东西时——比如,执行一个 $LOAD "*",8,1,从你那慢得像在冬眠的磁带机上加载数据——整个商店就必须关门歇业。

前台(你的键盘)“冻结”了。顾客(你自己)被完全无视。你只能绝望地盯着那个不断闪烁的光标,祈祷磁带机不要卡带,因为你的“员工”正忙着呢,没空搭理你3

这就是“单线程的堵车”。它简单,它可预测,但它也……慢得令人发指。

旅行者,在你沉睡的时候,我们这些你的后辈,曾经在很长一段时间里,以为我们靠“堆料”就解决了这个问题。

物理学的背叛:免费午餐的终结与“功率墙”

在你沉睡的头二十年里,我们(你的后辈)简直活在天堂。我们享受着科技史上最长、最美味的“免费午餐”5

我们甚至不需要写出多好的代码。我们今年写的任何“垃圾”代码,到了明年,都会因为新一代CPU的问世而自动跑得飞快。我们把这个奇迹称为“摩尔定律”。我们以为这种好日子会永远持续下去。

但后来,大概在2004年到2006年之间,我们一头撞在了一堵物理学砌成的叹息之墙上6

为了让你理解接下来发生的一切——以及你的世界观为何必须被重塑——你必须先理解这场悲剧。

登纳德缩放定律(Dennard Scaling)的终结

在你的时代,以及之后的很长一段时间里,我们都遵循着一个神圣的定律,名叫“登纳德缩放定律”7。这个定律的美妙之处在于:晶体管越小,它消耗的功率和电压也越低7

这意味着什么?这意味着我们可以在同一块芯片上塞进两倍的晶体管,同时大幅提高它们的运行速度(时钟频率),而不需要担心功耗和散热问题7。这就是“免费午餐”的来源:CPU每年都在(物理上)变得更小、更快、更强,而你的代码一行都不用改。

“功率墙”(The Power Wall)的降临

然而在21世纪初,这个定律失效了。我们把晶体管做得太小了。当晶体管的尺寸逼近纳米级别时,一个幽灵出现了:漏电(Subthreshold Leakage)5

用一个简单的比喻:想象你的CPU里有几十亿个微型水龙头(晶体管)。在过去,当你“关掉”一个水龙头时,它是能完全关死的。但当我们把水龙头做得太小(比如薄至几个原子的门氧化层),它就开始“滴水”了——就算在“关闭”状态,也会有微小的电流(漏电)穿过去5

一个水龙头滴水没什么大不了。但几十亿个水龙头一起滴水呢?5 这就汇成了一场无法控制的洪水(巨额功耗)。

而根据物理学定律,所有消耗的电能,最终都会变成一样东西:热8

时代的悲剧:英特尔的“PresHot”

这场危机的顶点,是一个代号为 “Prescott” 的CPU核心8

在2004年,英特尔推出了基于 Prescott 核心的 Pentium 4 处理器。他们的计划是宏伟的:他们相信自己可以把时钟频率一路推高到 4 GHz、5 GHz,甚至(在后来的计划中)达到 10 GHz!8

他们失败了。而且败得极其惨烈。

Prescott 核心成了CPU界的“切尔诺贝利”。由于恐怖的漏电问题,它的功耗和发热完全失控。它在“空闲”状态下(什么都不干)的温度就高达50摄氏度(122华氏度);而在全速运行时,它散发的热量是如此巨大,以至于普通的散热器根本压不住它,系统会过热并自动关机8

愤怒的用户们很快给它起了一个流传千古的外号:“PresHot” (Pres-“热”)8

英特尔的首席架构师之一,Bob Colwell,后来承认他正是因此而离职,因为他意识到这条追求高频率的道路是一个“技术上的死胡同”(a technologically finished dead end)5

这就是“功率墙”(The Power Wall)8。我们无法再让一个核心变得更快了,再快它自己就熔化了。

被迫的转向:从“更快”到“更多”

这场“PresHot”灾难,标志着一个时代的结束。CPU制造商们(如英特尔和AMD)被迫承认失败,并调转了整个行业的发展方向8

既然我们无法再制造出一个更快的核心,那我们唯一的办法,就是在同一块芯片上,塞进多个(相对较慢、也更凉快)的核心7

这就是“多核处理器”诞生的真正原因。

旅行者,请你务必理解这个关键的转折点:多核处理器的出现,不是工程师们的“锦上添花”,而是物理学的一次“背后捅刀”。

我们从追求“更快”(高频率),被迫转向了追求“更多”(多核心)。

这对你,这位“恐龙级”程序员,意味着什么?

这意味着你那个“一个仆人”的幸福年代,永久地结束了。你(的程序)不再是CPU唯一的“主人”。你现在必须学会的,不再是“指挥”,而是“管理”——管理一群(潜在的)乌合之众。

欢迎来到“管理一个团队”的新时代。

“厨房噩梦”:欢迎来到并发地狱

好了,你现在拿到了一台搭载“酷睿四核”处理器的新电脑。你该如何理解它?

让我们来启用大纲中的核心比喻:“厨房噩梦”(Kitchen Nightmare)9

你的新CPU(比如一个四核处理器)就像一个拥有四个灶台的专业厨房9。理论上,你可以同时烹饪四道菜,效率是过去的四倍。

但问题来了:这个厨房虽然有四个灶台,但管理极其混乱。这四个灶台(核心)必须共享唯一的刀具、唯一的砧板、唯一的盐罐和唯一的胡椒瓶10。这就是“共享内存”(Shared Memory)11

而你的程序,现在被分成了四个“厨师”(线程),他们试图同时在这个厨房里工作。

混乱,即将开始。

但在我们描述混乱之前,我们必须(像一个合格的博士研究员那样)先厘清两个让你抓狂的新词汇:进程(Process) 和 线程(Thread)。

  • 进程(Process): 在我们的类比中,一个“进程”就是一家独立的餐厅(比如你打开的 “Chrome 浏览器”)。它有自己的完整资源:自己的厨房、自己的菜单、自己的员工、自己的地盘12
  • 线程(Thread): 一个“线程”则是餐厅厨房里的一个厨师12

关键的区别在于隔离性12

  1. 两家不同的餐厅(进程A 和 进程B)互不相干。它们拥有各自独立的厨房(内存空间)。A餐厅的厨师(线程)绝对不可能跑到B餐厅的厨房里(除非操作系统特许),A餐厅着火了(崩溃),B餐厅也照常营业。
  2. 但是,同一个餐厅里的所有厨师(线程1、2、3)必须共享同一个厨房(同一个进程的内存空间)12

这就是地狱的根源。在你的80年代,你的程序是“一家只有一个厨师的小店”。而一个现代的程序(比如一个游戏)是“一家有几十个厨师、但只有一个厨房的餐厅”。

为了让你更清晰地理解这种新型的“组织架构”,这里有一份对比表格:

对比:进程(餐厅) vs. 线程(厨师)

特性 进程 (Process) 线程 (Thread)
类比 拥有独立厨房的“餐厅” 在厨房里干活的“厨师”
定义 正在运行的程序的实例12 进程内部的执行单元13
内存空间 独立。进程间内存隔离12 共享。同一进程的所有线程共享内存12
创建开销 高。启动一个新进程就像开一家新餐厅。 低。创建一个新线程就像多雇一个厨师12
隔离性 强。一个进程崩溃通常不影响其他进程12 弱。一个线程的错误可能导致整个进程崩溃12
通信 复杂。需要操作系统介入(IPC)。 简单。直接读写共享内存(但也因此危险)。

假装的“同时”:上下文切换(Context Switching)

“等等,” 你可能会问,“如果我只有一个CPU(单核)呢?那不就又回到‘一个厨师’的年代了吗?”

问得好。答案是:不。

因为现代操作系统(OS)是一个“诡计多端”的管理者。就算只有一个CPU(一个灶台),操作系统也会假装它在“并发”执行。

它是怎么做到的?通过一项名为“上下文切换”(Context Switching)的杂耍技艺14

让我们再次使用类比。这个单核CPU,就像一个患有严重“多动症”(ADHD)的厨师15

  1. 他开始炒一道宫保鸡丁(线程A),刚把鸡丁扔下锅……
  2. 突然,操作系统(厨房经理)拍了一下他的后脑勺:“停!别炒了!马上去切洋葱(线程B)!”
  3. 厨师(CPU)只好放下炒勺,记住“鸡丁刚下锅,还没放盐”,然后跑去切洋葱15
  4. 他刚切了两刀……
  5. 经理又来了:“停!别切了!马上去和面(线程C)!”
  6. 厨师(CPU)只好放下菜刀,记住“洋葱切了两刀”,然后跑去和面……

这个过程每秒钟会发生几百甚至几千次。因为切换速度极快,在人类看来,就好像这个厨师(CPU)“同时”在炒菜、切菜、和面15

这项“杂耍”是有巨大代价的。这个代价就叫“开销”(Overhead)15

厨师每次“切换”任务,都必须执行一套固定流程:放下旧工具、洗手、擦干、找到新工具、拿出新菜单、回忆上次干到哪了……15

这个“切换”本身不产生任何价值。它纯属管理开销16。如果厨房经理(操作系统)疯了,让厨师每秒钟切换一万次,那这个可怜的厨师就会把他所有的时间都花在“洗手和回忆菜单”上,而一道菜也做不完。

这种灾难,我们称之为“系统抖动”(Thrashing)15

“厨房噩梦”催生的新怪物(上):竞争条件 (Race Condition)

好了,旅行者。我们已经搭好了舞台:一个拥挤的厨房(共享内存),一群厨师(多线程)在里面同时开工。

现在,让我们欢迎地狱里的第一批“新怪物”。

Insight 2 (The "Hell" is Shared Memory): 地狱的根源来了。只要你允许多个厨师(线程)同时去碰同一个东西(共享内存),灾难就不可避免。

第一个怪物,名叫“竞争条件”(Race Condition)9

  • 定义: 当两个或多个线程试图同时访问(读/写)同一个共享资源时,最终的结果取决于它们执行的“精确顺序”(即,谁“跑”得快)9
  • 厨房类比: 厨师A 和 厨师B 都想给汤加盐。
    1. A拿起盐罐(读取“盐量=0”)。
    2. B也拿起盐罐(读取“盐量=0”)。
    3. A加了一勺盐(计算“0+1=1”),把盐罐放回去(写入“盐量=1”)。
    4. B也加了一勺盐(计算“0+1=1”),把盐罐放回去(写入“盐量=1”)。
  • 结果: 两个厨师都加了盐,但汤里只有一勺盐。为什么?因为B在A放回盐罐之前就拿起了“旧”的盐罐。

这个Bug听起来很蠢,但在真实世界里,它会导致银行系统凭空印钱。

经典剧本:银行账户的“魔术”17

这是并发教学的“圣杯”案例17。我们必须像分析犯罪现场一样,详细重现它。

  • 场景: 你的银行账户(一个共享内存地址)余额为 $500。
  • 你(这个魔鬼): 同时在两台ATM机上(线程A 和 线程B),分别取款 $50017

理想情况(单线程的“小店”)

如果银行系统是你的C64,一次只做一个任务,那么剧本是这样的:

  1. 机器A (线程A): 开始执行。
  2. 机器A: 检查余额($500)。够。
  3. 机器A: 计算新余额 ($500 - $500 = $0)。
  4. 机器A: 写入新余额 ($0)。
  5. 机器A: 吐钱 $500。
  6. 机器B (线程B): 开始执行。
  7. 机器B: 检查余额($0)。不够。
  8. 机器B: 拒绝交易。
  • 结果: 你取出了 $500,账户余额 $0。银行系统安全。

“竞争条件”下的地狱剧本17

现在,我们来看看在现代多线程“厨房噩梦”中,因为“上下文切换”的时机不对,会发生什么。请看好,魔术要开始了:

时间点 线程A (ATM 1) 内存中的余额 线程B (ATM 2) CPU在干嘛?
T1 开始 $500 运行线程A
T2 1. 检查余额。balance = 500。够用。 $500 运行线程A
T3 (准备计算 500 - 500) $500 上下文切换! (经理拍了A的后脑勺)
T4 (暂停) $500 开始 运行线程B
T5 (暂停) $500 1. 检查余额。balance = 500。够用。 运行线程B
T6 (暂停) $500 (准备计算 500 - 500) 上下文切换! (经理又拍了B的后脑勺)
T7 2. 计算新余额: 500 - 500 = 0 $500 (暂停) 运行线程A
T8 3. 写入新余额 0 $0 (暂停) 运行线程A
T9 4. 吐钱 $500 $0 (暂停) 运行线程A (任务完成)
T10 (结束) $0 上下文切换! (B厨师回来了)
T11 $0 2. 计算新余额: 500 - 500 = 0 运行线程B
T12 $0 3. 写入新余额 0 运行线程B
T13 $0 4. 吐钱 $500 运行线程B (任务完成)

魔术揭晓:

你从两台机器里,各自取走了 $500,总共 $1000。而你的账户余额……最后是 $0。

银行白白损失了 $50017

这就是“竞争条件”的恐怖之处。它不是一个语法错误。你的代码(balance = balance - amount)看起来完美无缺。但它在千万分之一的概率下,会因为CPU切换的时机(T3和T6)不对,产生灾难性的后果。

这是一个潜伏在代码里的幽灵。

“厨房噩梦”催生的新怪物(下):死锁 (Deadlock)

如果说“竞争条件”是两个厨师动作太快、互相干扰,那么第二个怪物“死锁”(Deadlock)则恰恰相反:它是两个(或多个)厨师互相谦让,直到饿死。

  • 定义: 两个或多个线程(厨师)各自占有对方需要的一个资源(工具),并互相等待对方释放,导致所有线程都被永久阻塞,程序“卡死”9

学术经典:餐饮哲学家问题18

在学术界,我们用一个著名的问题来描述死锁:“餐饮哲学家”(Dining Philosophers Problem)18

  • 场景: 五个哲学家(线程)围着一张圆桌,思考人生。
  • 资源: 他们面前有意大利面,但桌上只有五根叉子(资源),每两个哲学家之间放一根。
  • 规则: 思考不需要工具。但吃面(访问共享资源)必须同时使用两根叉子(左手和右手的)18

现在,让我们看看“死锁”剧本是如何上演的:

  1. 五个哲学家同时结束了思考,他们都饿了。
  2. 他们同时执行了第一个动作:“拿起我左手边的那根叉子。”18
  3. 灾难发生。

请想象这个画面:桌上所有的五根叉子,此刻正被五只左手握着。

  • 哲学家1:拿着左手的1号叉,等待右手的2号叉。
  • 哲学家2:拿着左手的2号叉,等待右手的3号叉。
  • 哲学家3:拿着左手的3号叉,等待右手的4号叉。
  • 哲学家4:拿着左手的4号叉,等待右手的5号叉。
  • 哲学家5:拿着左手的5号叉,等待右手的1号叉。

每个人都占有了一个资源,同时在等待一个“永远不会被释放”的资源(因为他的邻居也正在等待)。没有人会松手。他们将永远保持这个姿势,直到饿死19

这就是“死锁”。

本章高光:死锁的终极类比——那场“要命的面试”20

“餐饮哲学家”还是太学术了。在程序员的幽默文化中,我们有一个更完美的、堪称“史诗级”的类比来解释“死锁”。它来自一个古老的网络段子,我们必须完整地复现它20

【剧本开始】

  • 场景: 你去一家高科技公司面试。
  • 资源: 你需要“Offer”(资源A)。面试官需要“对‘死锁’的解释”(资源B)。

面试官: “你非常优秀。现在,请你解释一下什么是‘死锁’(获取资源B),我们就雇佣你(释放资源A)。”

: “(微笑)我不能解释。除非你先给我Offer(获取资源A),我才会解释(释放资源B)。”

面试官: “我不能给你Offer(锁住资源A),除非你先解释什么是‘死锁’(等待资源B)。”

: “(保持微笑)我不能解释(锁住资源B),除非你先给我Offer(等待资源A)。”

面试官: “……”

: “……”

(两人都卡住了。他们各自“持有并等待”(Hold and Wait)对方的资源,形成了“循环等待”(Circular Wait),并且资源“不可抢占”(No Preemption),面试官不能强行撬开你的嘴。死锁的四大必要条件(18)完美达成。)

(僵持了十分钟后)

面试官(恼羞成怒): “保安!保安(线程C)!把这个人给我赶出去!”

保安(在门外): “抱歉,长官,我进不去(阻塞)。”

面试官: “为什么?!(等待资源‘保安’)”

保安(线程C): “面试还没结束(等待资源‘面试结束’),按照规定,我不能打断面试。”

面试官: “我现在就宣布面试结束了!(试图释放‘面试结束’资源)”

(线程A): “(冷笑)你不能结束。我还没解释‘死锁’呢(持有资源B,而‘面试结束’依赖于资源B)。”

保安(线程C): “他说的对,长官。他必须先解释‘死锁’(等待资源B),你才能宣布面试结束(释放‘面试结束’资源),然后我才能进去(获取‘面试结束’资源)。”

面试官: “……” (面试官现在在等保安,保安在等你,你又在等面试官)

(全剧终)

这就是“死锁”:一个由“资源”和“等待”构成的、无法解开的逻辑闭环。

人类的反击(1):用“纪律”来驯服怪物

好了,旅行者。我们已经被“竞争条件”搞得账户透支,又被“死锁”困在了面试房间。我们(现代程序员)是如何在这样的地狱里幸存下来的?

Insight 3 (The Three Escape Hatches - Philosophy 1): 第一个解决方案是“纪律”。

既然问题出在“共享”——多个厨师同时去抢盐罐——那我们就制定一个严格的“规矩”:在厨房(共享内存)里,某些关键物品(共享资源)上,必须加一把锁(Lock)。

当一个厨师(线程)要用盐罐时,他必须先“锁”住它。用完之前,谁也别想碰。

为了让你(再次)理解这种“纪律”,我们必须请出并发教学的第二个“圣杯”类比:“加油站的卫生间”21

互斥锁(Mutex,Mutual Exclusion)

这是最简单、最粗暴,也是最常用的一种锁。

  • 类比(21): 想象一个加油站,它的卫生间是单间的,门上只有一把钥匙。
  • 工作流程:
    1. 你(线程A)内急,跑到前台拿走了这把唯一的钥匙(执行 lock())。
    2. 你进入卫生间,把门锁上(获得“互斥”访问权)。
    3. 此时,线程B 和 线程C 也来了。他们发现钥匙没了,只能在门口排队(线程被“阻塞”)。
    4. 你办完事(访问完共享内存),出来,把钥匙还给前台(执行 unlock()21
    5. 排在第一位的线程B,拿到钥匙,进去,锁门。
  • 关键特性:所有权(Ownership)22
    • “互斥锁”的铁律是:谁上锁,谁解锁22
    • 你(线程A)拿着钥匙进去的,就必须是你(线程A)出来还钥匙。你不能说“我办完了,让我的朋友线程B帮我还钥匙”,这是绝对禁止的。这就是“所有权”的概念。

信号量(Semaphore)

这是另一种稍微高级点的“锁”,它管理的不是“互斥”,而是“数量”。

  • 类比(21): 想象另一个(更豪华的)加油站。它的卫生间是多间的(比如,有4个隔间),因此前台的墙上挂着4把一模一样的钥匙。
  • 工作流程:
    1. “信号量”的初始值,就是墙上钥匙的数量(Count = 4)23
    2. 你(线程A)来了,从墙上拿走一把钥匙(执行 wait()P(),Count 减为 3)。
    3. 线程B 来了,拿走一把钥匙(Count = 2)。
    4. 线程C 来了,拿走一把钥匙(Count = 1)。
    5. 线程D 来了,拿走一把钥匙(Count = 0)。
    6. 此时,钥匙没了。线程E 来了,他发现墙上没钥匙,只能在门口排队(线程被“阻塞”)24
    7. 线程A 办完事出来了,把钥匙挂回墙上(执行 signal()V(),Count 增为 1)。
    8. 线程E 看到有钥匙了,(被系统唤醒)拿走钥匙,进去。
  • 关键特性:无所有权(No Ownership)22
    • “信号量”的钥匙是通用的,它只是一个“计数器”22
    • 你(线程A)拿钥匙进去,你完全可以让你的朋友(线程B)在你出来时,帮你往墙上挂回一把钥匙(执行 signal())。系统不在乎是谁还的,它只在乎墙上的钥匙“总数”是否正确。
    • 因此,Mutex 是一种“锁定”机制,而 Semaphore 是一种“信号”机制24

为了帮你区分这两个“卫生间管理员”,请看下表:

对比:互斥锁 (Mutex) vs. 信号量 (Semaphore)

特性 互斥锁 (Mutex) 信号量 (Semaphore)
类比 只有1把钥匙的单间卫生间21 有N把相同钥匙的N间卫生间21
目的 互斥(Mutual Exclusion)。保护资源,同一时间只许一个线程访问22 同步(Signaling)。控制并发数量,同一时间允许N个线程访问22
所有权 有。谁 lock,就必须由谁 unlock22 无。任何线程都可以执行 wait 或 signal22
类型 是一个“锁”(Locking Mechanism)24 是一个“信号”或“计数器”(Signaling Mechanism)24

“锁”的诅咒

用“锁”来解决问题,听起来很完美,对吗?

错了。

“锁”这种“纪律”,本身就是新地狱的开始。

  1. 你忘了还钥匙(忘记 unlock): 你(线程A)拿了钥匙进了卫生间,然后(因为代码Bug)在里面睡着了。结果:所有其他在外面排队的线程(B、C、D……)将永远等待下去。整个程序卡死。
  2. “死锁”的回归: 那个“餐饮哲学家”的问题,就是因为“锁”而产生的。
    • 哲学家A:锁了“左手的叉子”,等待“右手的叉子”。
    • 哲学家B:锁了“右手的叉子”,等待“左手的叉子”。
    • 恭喜,你们又“死锁”了。

事实证明,依靠“纪律”和“规范”来管理共享内存,是极其困难且反人类的。我们花了30年时间,才意识到一件事:

既然“共享”是万恶之源,那我们为什么不干脆……停止共享呢?

终极飞跃:与其“锁门”,不如“分家”

旅行者,这可能是本章最“反直觉”,也是最“现代”的一部分。

我们花了30年时间,试图用各种“锁”(Mutex, Semaphore, Monitor...)来驯服“共享内存”这头怪兽,结果被它折磨得死去活来。

Insight 2 (Revisited): 地狱的根源,是“共享的、可变的状态”(Shared, Mutable State)。

Insight 3 (Philosophies 2 & 3): 真正的“复活”,是采纳两种(或三种)全新的哲学。与其“加锁”,不如“分家”。

方案一(FP的复兴):哲学之“复制” (Replication)

你还记得我们在第七章(大纲)里提到的“函数式编程”(FP)吗?那个60年代的老古董,在2010年代突然“文艺复兴”了9

为什么?因为“功率墙”5

“功率墙”把我们逼进了多核地狱,而FP恰好是逃出地狱的“秘密通道”。它的核心思想,就是**“不可变性”(Immutability)**25

  • 回到厨房的比喻: 我们受够了厨师们为了一罐盐(共享内存)打架。
  • FP的解决方案: 太简单了。我们不“共享”盐罐了。当一个厨师需要盐时,我们就直接复制一个全新的盐罐递给他25
  • 定义: “不可变性”意味着数据一旦被创建,就永远不能被修改。
    • 你不能“改”一个变量的值。
    • 你只能“创建”一个包含新值的副本(Copy)。
  • 这如何解决并发问题?
    • 答案是:它从根源上消灭了问题。
    • 如果一个数据(那罐盐)永远不会被“改变”,那它就可以被1000个线程(厨师)同时读取,而绝对不会发生“竞争条件”25
    • 既然没有“竞争”,你自然也就不需要“锁”。
    • 没有锁,就没有伤害。没有锁,就(几乎)没有死锁。25

这就是为什么FP这个“学术玩具”会突然复兴的唯一原因:在“多核危机”爆发时,它是我们能找到的、唯一一个(在理论上)绝对安全的并发模型。

方案二(Actor模型):哲学之“隔离” (Isolation)

FP的“复制”哲学,在某些情况下(比如超大数据)开销很大。于是,第二种哲学诞生了:彻底的“隔离”。

这就是“Actor 模型”(Actor Model)26

  • 核心思想: 真正的“无共享内存”。
  • 类比:“购物车” Actor 军团27
    • 想象一个大型电商网站,有100万个用户正在购物。
    • 在Actor模型里,系统会创建 100万个 “购物车 Actor”。
  • 工作流程(26):
    1. 每一个 Actor(比如“购物车-A”)都是一个独立的“小个体”(像个微型进程),它拥有自己的私有状态(比如 items = [土豆, 鸡蛋])。这个状态,外界绝对无法访问27
    2. 你(比如 线程X)想往 购物车-A 里加一个苹果。你不能直接去修改它的 items 列表(因为你根本碰不到它)。
    3. 你唯一能做的,是给 购物车-A 发一条消息(Message),就像发一封电子邮件:“嘿,购物车-A,请帮我加一个‘苹果’。”26
    4. 购物车-A 内部有一个“信箱”(Mailbox)。它会(在它有空的时候)收取这封信,在它自己的(单线程)世界里,安全地把“苹果”加进列表。
  • 这如何解决并发问题?
    • 它也从根源上消灭了问题。
    • 每个Actor内部都是单线程的27。它在处理自己的私有数据时,不需要任何“锁”。
    • Actor 之间唯一的交流方式是“异步消息”26。你给我发信,我给你发信。我们从不触碰对方的“内存”。
    • 没有共享,就没有竞争,就没有死锁。

方案三(CSP模型):哲学之“有组织的隔离”

这是第三种哲学,也是现代 Go 语言背后的核心思想,称为“通信顺序进程”(Communicating Sequential Processes, CSP)28

  • 核心思想: 它也反对“共享内存”,但它采用了一种更“有组织”的通信方式。
  • 圣经格言: “不要通过共享内存来通信;而要通过通信来共享内存。”(Don't communicate by sharing memory; share memory by communicating.)29
  • 类比:“星巴克”的奇迹30
    • CSP/Go 的并发模型,就像一个运营极其高效的星巴克咖啡店。
    • 角色: 顾客(一个Go程,Goroutine)和 咖啡师(另一个Go程)。
    • 关键点: 顾客和咖啡师从不直接对话。他们甚至不需要知道对方的存在。
    • 他们之间有两个“传送带”(通道,Channels)30
      1. 顾客(线程A)把写好的订单(数据),扔进“点餐通道”(orders <- "拿铁")。
      2. 咖啡师(线程B)守在“点餐通道”的另一头,从里面取出订单(drink := <- orders)。
      3. 咖啡师做完咖啡,把成品(数据)放到“取餐通道”(drinks <- "拿铁")。
      4. 顾客(线程A)守在“取餐通道”的另一头,拿走他的咖啡(myCoffee := <- drinks)。
  • 这如何解决并发问题?
    • 数据的“所有权”在传递过程中是唯一的。
    • 当订单在“顾客”手里时,“咖啡师”碰不到它。当顾客把它扔进“通道”后,他就失去了对它的控制权。当“咖啡师”从通道取出它时,他是那一刻它唯一的“主人”。
    • 这种模型,就像田径接力赛里的“接力棒”。数据(接力棒)在同一时间只被一个线程(运动员)持有。
    • 这是一种极其优雅的、结构化的并发。你不是在“锁”数据,你是在“传递”数据。

结论:欢迎来到新世界,厨房(终于)干净了

亲爱的时空旅行者,我们这一章的旅程,堪称一场“炼狱”之旅。

我们从你所熟悉的、那个可预测的、但效率低下的“单线程堵车”年代3出发。

我们目睹了“物理学的背叛”——登纳德缩放定律的终结5和“PresHot”的悲剧8,它像一个无情的上帝,把我们所有人都从“单核”的天堂,踹进了“多核”的地狱。

我们一头扎进了“厨房噩梦”9,见识了那群(线程)厨师是如何在“共享内存”这个(唯一的)厨房里制造混乱的。

我们认识了两个全新的、只在噩梦中才有的怪物:

  1. 竞争条件(Race Condition): 我们观摩了那场精彩的“银行魔术”17,两个线程如何合谋,从$500的账户里取出了$1000。
  2. 死锁(Deadlock): 我们旁听了那场(永远无法结束的)“要命的面试”20,见证了三个线程(面试官、你、保安)是如何互相等待,直到天荒地老。

我们也看到了人类的反击。

我们先是尝试了“纪律”——我们发明了“卫生间钥匙”(Mutex 和 Semaphore)21,试图用“锁”来规范厨师们的行为。结果发现,“锁”本身就是一种更复杂、更容易出错的新地狱。

最终,在经历了30年的痛苦之后,我们迎来了真正的“飞升”。我们意识到,解决“共享”问题的最好办法,就是**“停止共享”**。

我们走向了(古老的)未来:

  • 函数式编程(FP)说:“给每个厨师复制一套厨具。”25
  • Actor模型说:“给每个厨师隔离一个单间。”26
  • CSP模型说:“让厨师们通过传送带传递工具。”30

旅行者,你并非过时。你只是幸运地跳过了我们(你的后辈)在“锁”的地狱里挣扎的那段“弯路”。

你所熟悉的“隔离”和“线性”的思维,在今天非但没有过时,反而成为了最高级的并发哲学的核心(Actor 和 CSP 内部都是线性的)。你只是需要适应,这个世界上,现在有了一万个“你”,在同时运行。

欢迎来到新世界。厨房很忙,但这一次,我们(终于)有序了。


引用的著作


  1. Commodore 64 - Wikipedia, 访问时间为 十月 27, 2025 ↩︎

  2. Compiling the Original Commodore 64 KERNAL Source | Hacker ..., 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎

  3. Threads Made Simple: Understanding Single vs. Multi-Threading for Beginners - Medium, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎

  4. Learn Difference Between Multithreading and Single-Threading - Codefinity, 访问时间为 十月 27, 2025 ↩︎

  5. The End of Dennard Scaling - YouTube, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  6. Lecture 15: Moore's Law and Dennard Scaling, 访问时间为 十月 27, 2025 ↩︎

  7. Classic Moore's Law Scaling Challenges Demand New Ways to ..., 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎

  8. The Power Wall For CPU Chips - Edward Bosworth, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  9. 程序员复活手册:从BASIC到AI(大纲) ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  10. multithreading Memes - ProgrammerHumor.io, 访问时间为 十月 27, 2025 ↩︎

  11. Shared memory - Wikipedia, 访问时间为 十月 27, 2025 ↩︎

  12. Threads vs. Processes: How They Work Within Your Program, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  13. Processes and Threads | Operating Systems (OS) | Core Computer Science - work@tech, 访问时间为 十月 27, 2025 ↩︎

  14. Context switch - Wikipedia, 访问时间为 十月 27, 2025 ↩︎

  15. Context Switching Explained: Unveiling Its Hidden Costs | by ..., 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  16. Understanding Context Switching and Its Impact on System Performance - Netdata, 访问时间为 十月 27, 2025 ↩︎

  17. Hacking Banks With Race Conditions | by Vickie Li | The Startup ..., 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  18. Dining philosophers problem - Wikipedia, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  19. Dijkstra's dining philosophers problem anyone? : r/ProgrammerHumor - Reddit, 访问时间为 十月 27, 2025 ↩︎

  20. Deadlock explained. : r/ProgrammerHumor - Reddit, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎

  21. Mutex vs Semaphore Using a Gas Station Bathroom Analogy, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  22. multithreading - Difference between binary semaphore and mutex ..., 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  23. Diving Deep With SEMAPHORE And MUTEX | by Ankit Singh - Medium, 访问时间为 十月 27, 2025 ↩︎

  24. Mutex vs Semaphore - GeeksforGeeks, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎

  25. Functional Programming and Immutable Data Structures in Modern ..., 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  26. Actor model - Wikipedia, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  27. How the Actor Model works by example - TheServerSide, 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎

  28. Communicating sequential processes - Wikipedia, 访问时间为 十月 27, 2025 ↩︎

  29. The Big Myth of Functional Programming: Immutability Solves All Problems - Blog, 访问时间为 十月 27, 2025 ↩︎

  30. Go, Concurrency, and Starbucks. Communicating sequential ..., 访问时间为 十月 27, 2025 ↩︎ ↩︎ ↩︎