书评:《并行编程难吗?如果难,你能做些什么?》
文章摘要
Andrew Helwer 在 2026 年 9 月 21 日写下的一篇教材书评,评的是 Paul E. McKenney 的免费在线教材《Is Parallel Programming Hard, And, If So, What Can You Do About It?》(并行编程难吗?如果难,你能做些什么?)。McKenney 是 Linux 内核 RCU(Read-Copy-Update)同步机制的作者。Helwer 说自己读了这本书的很大一部分,已经”吃饱了”,短期内大概不会再读,所以趁印象还新鲜写下这篇评论。
阅读的心境与环境。 他先写了一段”像菜谱网站那种啰嗦的个人前情提要”。过去十年他的职业生活都围绕 TLA⁺ 和分布式系统,觉得是时候换换了。2022 年他曾尝试(并失败)转向 Lean,想得到一份”极其小众、根本不存在”的工作——专门替研究人员把量子信息处理成果形式化的人。他在那件事上燃尽了,而考虑到最近自动定理证明的进展,他调侃说那也许是自己的神经系统”暂时有了先见之明,帮我躲过了一颗子弹”。带着这样的背景,他参加了 2026 年在密苏里州哥伦比亚市举办的 Software Should Work 大会,特别喜欢 Filip Pizlo 关于 Fil-C 的演讲,会后也和 Pizlo 聊了不少解释器和并发的话题。他本以为自己从 TLA⁺ 和分布式系统那里对并发已经懂得不少,但 Pizlo 讲了编写并发无锁垃圾回收器的难处,他才意识到自己其实知之甚少——”觉得自己懂得很少,正是一场好会议的标志”。Pizlo 还提到,TLA⁺ 对于推理真正同时发生的事件(在多核 CPU 上确实可能)可能不太有用,至少不太顺手,并强调了分析并发算法可线性化性(linearizability)的重要性。
于是他想找一本侧重无锁、而不是基于互斥锁或消息传递模式的并发教材。McKenney 这本书关注的是通用并发编程和 CPU 缓存效应,而非专门讲如何写无锁数据结构,看起来正合适,HN 上也有过好几次(2011 到 2023 年间)讨论。他认为花时间陷入分析瘫痪、寻找那本”恰好对”的教材,”其实只是一种巧妙的拖延”,这本就够好了。阅读地点是他回加拿大一片安静林区探亲的一周半假期;2026 年恰好是个可怕的蚊子季,他大部分时间坐在凉爽的纱窗门廊里,”被数百名守卫尽职地看守着,确保我不离开岗位”。
格式问题。 这本书有三种 PDF 版本:像论文那样的双栏、带宽页边距的单栏、没有页边距的单栏。最后一种非常适合他的 Pine64 PineNote 电子墨水阅读器。但书中内部链接极多:有些用于知识小测验,点击就跳到答案,这很好;另一些用于引用图表、章节和大量脚注与参考文献,这就非常烦人,他认为至少该删掉 80%。如果阅读器没有物理翻页键,你的阅读体验就是不停地在想翻页时误点某个链接,被传送到不知道哪里去;想用连续点击在两节之间快速来回翻也基本不可能。有些地方两个链接紧挨着,触屏根本没法精准点到其中一个。关掉所有链接能解决问题,但那样就用不了那些很棒的测验题,所以他只好忍着。
入门章节。 他认为这本书对他的水平来说几乎是完美的。前两章是轻松的介绍和动机,第 3 章《硬件及其习性》开始进入正题,讲现代 CPU 在高层面上如何工作、什么让它快、什么让它慢,还配了很多幽默插图。第 3.2.1 节带读者一步步看一个 CPU 核心写入一个不在自己缓存中的内存地址时发生了什么,这是他觉得真正有意思的地方。一个遗憾是书里没有基础地讲解 MESI 缓存一致性协议(只在附录提到),他完全没有 CPU 缓存如何协调并发读写的直觉,是自己上网查到 MESI 后才读懂后面的内容。了解 MESI 也让他明白了:多个 CPU 核心其实不能真正同时写同一个内存位置。x86 CPU 并不直接写内存,只写自己的缓存;核心只有在独占包含该地址的缓存行时才能写入,另一个核心同时要写就得等缓存行的独占权转移过来。所以真正同时的写入不会发生,不过如果写的数据跨越多个缓存行,还是可能出现撕裂写。
第 4 章《行业工具》则”彻底颠覆认知”。如果你写并行程序时不够小心,编译器会尝试匪夷所思的创造性优化,导致完全荒谬的行为。第 4.3.4.1 节《共享变量的恶作剧》列举了一系列可怕的事故:读撕裂、写撕裂、读合并、写合并、代码重排、凭空产生的读、凭空产生的写(尤其过分)、写转读,以及死代码消除。代码即便毫发无损地通过了编译,CPU 在执行时还会再玩一轮花样。这双重麻烦让他很难在熟悉的互斥锁和消息传递之外理清并行程序。他对这一章的不满是它太偏向 Linux 内核语境:他本想了解 C++11 和 C11 在形式化并行编程方面做的工作(如 std::memory_order),但这些只在 4.2.6、4.2.7 节各占一小段。读完这章,他只留下一个模糊印象:ACCESS_ONCE()、WRITE_ONCE() 这些宏无非是把东西强转成 volatile*,这样就足以吓退编译器。一些很有趣的历史发展,比如关于”良性数据竞争”算不算错误的争论,则完全略过了。
主体章节。 第 5 章《计数》大概是全书的招牌章节,也是他最后一个深入阅读的章节。它讲了约十种让多个线程递增同一个计数器的写法。显而易见又不出错的实现——每个线程都用原子递增指令——一开始就被性能数据否决了,糟糕得惊人,这时他课外学的 MESI 知识派上了大用场。本章最后讲到一种叫”信号窃取限制计数器”(signal-theft limit counter)的东西,他坦言自己没完全看懂,自己写计数器大概不会走那么远。他很喜欢基于数组的每线程统计计数器,它和分布式系统里的无冲突复制数据类型(CRDT)很相似,也是学习伪共享(false sharing)性能影响的绝佳载体——你不能把各线程的计数器塞进同一个连续数组就完事了。
第 5 章之后他基本是略读、找感兴趣的话题。有些章节偏概念,讲所有权、分区、延迟处理等,这些都很容易从分布式系统那边迁移过来。形式化验证一章用的是 Promela 和 Spin,作为 TLA⁺ 用户他没动力去学。验证一章的 11.6.4 节《追捕海森堡 bug》写得很好。无锁编程直到第 14 章《高级同步》才真正出现,到那时他已经准备换一本专讲无锁编程和数据结构的教材了;第 15 章终于讲到内存序,但他早已转向课外资料去解决自己在这方面的困惑。
总评。 虽然只深入读了前五章,他仍认为这本教材非常出色,因为它真正激发了他学习并行编程的渴望。现在每次午饭他都得努力克制,不把学到的各种稀奇古怪的东西倒给同事:你知道 release-consume 内存序形式化的失败吗?知道 x86 上用 mov 做的对齐读写是原子的吗?知道”凭空出现的值”(out-of-thin-air values)吗?知道 DEC Alpha 那极其弱的内存模型吗?知道 release-acquire 语义怎么工作吗?知道确定性模拟测试(目前)还无法有意义地测试无锁算法吗?知道 ARMv8 之前的原子操作可能被抢占吗?”这里有一座喜剧性反直觉的金矿。”他接下来想学高性能垃圾回收,欢迎推荐。
HN 评论精华
约 62 条评论,146 分。讨论的最大一块是”并行和并发到底是不是一回事”的术语之争,其次是推荐书单、调试并发 bug 的经验,以及一串玩标题语序的段子。
并行与并发之争。 criddell 指出这篇书评似乎把并行和并发当成同一件事,并引用 Rob Pike 在《Concurrency is not Parallelism》演讲中的名言:”并发是同时处理很多事情,并行是同时做很多事情。”作者 ahelwer 回应说这场仗早就输了,不同资料给的定义各不相同,有时恰好相反;而且他认为混用也没带来什么实际困惑。确实,并发程序可以被串行化到单核上运行(Antithesis 这类确定性模拟测试和 Mozilla rr 这类录制回放工具就是这么做的),”但严格划分两者并不会带来什么深刻的概念突破”。
Athas 不同意,认为恰恰有深刻的概念突破:并发关乎语义,并行是运行层面的属性。并发原语本质上是非确定性的(通常用于处理非确定性事件),我们要在其上建立某些性质;而许多有趣的并行操作是完全确定性的,它们”是并行的”只是其成本模型(以及实现)的属性。他认为这种区分正越来越被接受,值得为之坚持。miki123211 给了两个直观例子:并行而几乎无并发——解析一堆文件,每个文件调用一个不依赖全局状态的 parse(path) -> AST,并行化易如反掌,看不到一把锁;并发而无并行——一个以 IO 为瓶颈的数据库,多个客户端同时读写同一张表、事务隔离、节点间互相通信,即使跑在单核上也充满并发和讨厌的锁。packetlost 坚持两者是二元属性而非连续谱:代码可以有并发原语,但并行原语必然来自执行环境;”如果这个区分不重要,Python 那臭名昭著的 GIL 就不会是个问题”。kccqzy 附和:即便在有 GIL 的 Python 里,asyncio 单线程也要提供 Lock、Condition、Semaphore,threading 同样有自己的一套,GIL 阻止了有意义的并行,但正确使用并发工具仍是程序员的责任。jerf 则认为不该僵硬地区分,它们处在一个连续谱上,硬把代码归类为”并行”或”并发”可能弊大于利。Jtsummers 的立场比较折中:这种区分有用,但远非通用,几十年来两个词常被混用,最好是讨论者先声明自己的定义,而不是强迫别人接受。
adrian_b 写了一段系统性的阐述:并发进程指各部分执行顺序不可预测、可任意交错的进程;对正确性而言,只有顺序执行还是并发执行才重要,并发执行时任何执行顺序都不能改变结果,至于底层是单个硬件线程还是多个核心则无关紧要。正确的并发编程关键在于如何控制对共享资源的访问(互斥、乐观访问加重试,或者把数组、队列等共享资源动态划分为不相交的部分);并行只关乎性能,需要尽量减少强制串行的数据依赖、控制流依赖和资源依赖。他还指出,即便程序员写的是纯顺序程序,带乱序执行的现代 CPU 也会分析指令依赖,把它转成并发进程在多个执行单元上并行执行——这不是”无并发的并行”,只是并发由硬件调度器引入了。其他零星的反驳包括:MaxBarraclough 指出 SIMD 就是无并发的并行;gpderetta 反驳”单核上的并发通常是协作式的”,提醒抢占式并发几乎和交互式计算机一样古老。
形式化验证与测试。 thomasahle 认为并行编程是用 LLM 生成 Lean 正确性证明的绝佳应用场景:”你没法靠单元测试脱身,但如果在乎代码正确性,今天已经有办法了。”6gvONxR4sf7o 的体验相反:Lean 缺少线性类型或分离类型,过于专注谈论结果,难以整洁地描述结果是如何计算出来的。vitalnodo 推荐更轻量的模型检查器 Spin:用 Promela 小语言描述同步逻辑,Spin 会尝试所有可能的交错。tintor 认为多种测试混合就有帮助,最好的例子是 SQLite 和数据库领域的 Jepsen 测试套件。
书单推荐。 应作者征求推荐的号召,anonymousDan 推荐 Herlihy 和 Shavit 的《多处理器编程的艺术》作为并发编程入门,以及 Nagarajan 等人的《A Primer on Memory Consistency and Cache Coherence》深入内存一致性与缓存一致性的交互;RossBencina 推荐 Michael L. Scott 的《Shared Memory Synchronization》了解具体细节。bolangi 推荐 Raku(原 Perl 6)虚拟机作者 Jonathan Worthington 关于并行、并发与异步设计的演讲。
调试经验。 hoistway 说自己花了好几天追一个只在特定负载下出现的死锁。kccqzy 有些意外:死锁在他看来是最容易诊断的并发 bug 之一——看线程栈就知道谁卡在获取锁上,挂上调试器就知道涉及哪些锁,然后遵循”多把锁必须在所有代码路径中按相同顺序获取”即可;活锁难得多,而因缺失或错误同步导致的静默数据损坏最难。afdbcreid 补充这前提是你能挂调试器,线上只有部分卡住时就没那么简单。gpderetta 分享了一次真实经历:怀疑死锁但应用还在苟延残喘,没理由挂调试器,最后他靠 perf 和 /proc/<pid>/task/<tid>/wchan 确认了猜测;他还指出,当你抛开线程和互斥锁这些标准构件、自己造轮子时(如今的异步运行时很常见),就失去了调试器支持和栈追踪,逻辑任务死锁了线程却看起来一切正常。stingraycharles 回忆 25 年前作为年轻气盛的 C++ 程序员,才发现 map 上 return m[k] 在 k 不存在时并不是只读操作,从此明白 const 正确性在多线程程序里不只是锦上添花。Mbarley 说调试竞态条件像捉鬼,常常让他干脆改用消息队列,gpderetta 回了一句:”直到你发现队列也会有竞态条件。”
段子区。 cbm-vic-20 建议标题改成乱序版的《Is Parallel Programming What Can You Hard, And, If So, Do About It?》,随后 ozarkerD 接”world! hello”,nnevatie 补上”mutex 的复数形式是 deadlock”。bob1029 说应该先让开发者证明能负责任地用好一个核,再给他们 64 个,就像先让飞行员熟悉单引擎飞机再开 747;pjc50 补充很多被吐槽慢得要命的软件,恰恰是在”不负责任地使用一个核”——通常是在”只有一个线程能碰 UI”的环境里,而那个线程什么都要阻塞等待。kazinator 则总结了并行编程的生命周期:”一开始全是哇哦快看它跑多快,然后开始并行调试,转眼 15 年过去,换了个人接手,当初那位哇哦老哥早就走了。”