2.17

View in English

2.17 并发与并行

概述与动机

并发(Concurrency)是一种将程序组织成多个独立任务的艺术,这些任务可以各自推进,而不必彼此等待。并行(Parallelism)则是在多个处理器上真正同时执行这些任务。这一区别并非咬文嚼字。并发是一种组织代码的方式,使一次缓慢的网络调用不会冻结整个程序;并行则是一种通过将计算分摊到多个核心上来更快完成大型计算的方式。混淆这两者会导致团队为了追求速度而添加线程,结果只收获了 bug。

对于大型团队而言,这个主题之所以重要,是因为并发正是正确性悄然消失的地方。单个作者编写单线程代码时可以逐行推理,但一旦多个作者在多个线程之间共享内存,可能的交错(interleaving)数量就会爆炸式增长:一个通过了所有测试的程序,仍可能在生产环境负载下百万分之一的概率出错()不是以响亮的崩溃形式,而是以数据损坏、请求挂起、没有人能复现的事故形式出现。本章建立在第 2.16 章(性能工程)的代码层面视角和第 2.13 章的计算基础之上,并延伸到第 3.3 章(分布式系统)中的协调问题()那是跨机器的并发,还多了一层不可靠网络带来的残酷。

对企业而言,并发 bug 就是吞吐量 bug。高流量服务能否存活,取决于它是否能够在不对共享状态产生竞争的情况下处理成千上万的并发请求,而一个未同步的计数器就可能在负载下损坏账本。对政府而言,利害关系在于运行数十年、涉及安全、福利或公共记录的系统的正确性与可审计性。税务或医疗系统中的一次竞态不是小麻烦,而是一个错误答案,日后必须有人向监督机构解释。在这两种场景下,目标是一致的:让安全路径成为默认路径,这样接触代码的众多人员就不必人人都是并发专家。

关键原则

  • 并发是结构,并行是执行。 在动用线程之前,先弄清楚你真正需要的是哪一个。
  • 共享可变状态是敌人。 几乎每一个并发 bug 都可以追溯到两个任务在触碰同一份可变数据。
  • 优先选择不可变性与消息传递。 不能改变的数据就不会产生竞态,而消息传递在安全性上胜过共享内存。
  • 不确定性是核心难题。 那种千分之一才出现一次的 bug 才是问题本身,而不是边缘情况。
  • 为一切设置边界。 无边界的队列、线程数和在途工作量会把一次波动变成一场事故。
  • 更高层次的模型胜过原始的锁。 actor、channel 与结构化并发能为众多作者提供安全的默认选择,而每一把锁都是有代价的。
  • 测试交错情况,而不只是happy path。 确定性测试无法捕捉只有在罕见顺序下才会暴露的 bug。

建议

判断你需要的是并发还是并行

先给问题命名。如果你的服务大部分时间都在等待(数据库、网络调用或磁盘),那你面对的是 I/O 密集型工作负载,答案是并发:把代码组织成这样()当一个请求在等待时,其他请求可以继续推进。一个带有 async/await 的单线程,或一个小型线程池,就能服务成千上万个等待中的请求。反之,如果你的程序是 CPU 密集型的,几乎没有等待地在做计算,那么跨核心的并行才能带来速度提升,此时上限由 Amdahl 定律设定(参见第 2.16 章):无论你增加多少核心,串行部分都会限制你的加速比。在设计之前,先测量你处于哪种情形。

把共享可变状态视为敌人

几乎每一个并发缺陷都归结为同一种形态:两个任务在没有约定顺序的情况下读写同一份可变数据。这就是竞态条件(race condition),它会造成更新丢失、对象被写到一半,以及违反代码原本假定安全的不变量的值。最可靠的防御是减少共享可变状态:让每个任务拥有自己的数据、传递副本而不是引用,并把可变状态限定给单一所有者,其他任务通过消息去接触它。当你确实必须共享时,让共享变得显式且范围很小,以便评审者能看清状态在何处被触碰。

默认优先选择不可变性与消息传递

最安全的共享数据,就是不能改变的数据。一个不可变对象(immutable object)一旦构造完成,就可以被任意数量的线程读取而无需任何同步,因为根本没有可竞争的东西。把不可变性作为默认选择,把可变性当作刻意为之的例外。当任务之间必须协调时,优先选择消息传递而非共享内存:与其共享一个公共变量,不如让一个任务把值发送给另一个任务,这正是 Go 语言那句箴言”不要通过共享内存来通信;要通过通信来共享内存”背后的哲学。消息传递把不可见的、依赖于顺序的 bug 转变为显式的、可检视的数据流,对于由许多人维护的代码来说,这份清晰几乎总是值得为每条消息付出的成本。

在使用原始锁之前先考虑更高层次的模型

手写加锁在原理上是正确的,但在实践中往往是灾难性的,因为人类不擅长推理每一种可能的交错情况。优先选择让安全并发成为默认选项的模型。actor 模型为每个 actor 提供私有状态和一个邮箱:actor 之间从不共享内存,只发送消息,因此整整一类竞态问题就此消失。通信顺序进程(CSP),也就是 Go 等语言中 channel 背后的模型,让独立的进程通过带类型的 channel 传递值。结构化并发把并发任务的生命周期绑定到一个词法作用域上,因此任务不能比生成它们的代码块存活更久,错误也会向上传播而不是凭空消失。Async/await 让你能以顺序风格编写并发的、I/O 密集型代码。以上每一种模型都为普通作者提高了底线,而这正是大型团队所需要的。

理解你的内存模型、原子性与可见性

当你确实要共享内存时,有两个性质会咬人。原子性(Atomicity)意味着一个操作要么整体发生,要么完全不发生;一个普通的自增操作(x = x + 1)并不是原子的,因为它分三步进行()读取、相加、写回()而这三步之间可能被另一个线程打断,这正是计数器丢失更新的原因。可见性(Visibility)意味着一个线程的写入能够被另一个线程观察到;如果没有恰当的同步,一个核心上写入的值可能停留在缓存里,而另一个核心看不见,于是一个线程可能在一个早已被设置的标志位上无限循环。你所用语言的内存模型定义了写入何时变得可见,以及编译器和 CPU 可以对顺序做怎样的重排,因此你不能假定代码会按你写的顺序运行。使用语言提供的原子类型和同步原语,而不要自行发明无锁方案。

有意识地使用同步原语,并针对死锁进行设计

当共享无法避免时,选用恰当的原语,并正视它的代价。锁(lock)或互斥量(mutex,即 mutual exclusion)一次只允许一个线程进入临界区,但这会串行化访问,因此一把热点锁会成为瓶颈,抹平多核带来的好处。信号量(semaphore)限制同时可以推进的任务数量,这正是你限定一个池子规模的方式。原子操作(atomic operation)为计数器之类的简单值提供无锁更新,比锁更便宜,但对任何复合结构都很容易被误用。锁带来三种经典的失败模式。死锁(deadlock)是指多个任务在一个循环中互相等待,谁都无法推进,教科书式的例子是两个线程各持有一把锁,都想要对方那把。活锁(livelock)是指任务不断地对彼此做出反应,却没有任何进展。饥饿(starvation)是指某个任务始终得不到资源,因为其他任务不断插队。防范这些问题的规范是具体的:强制一个全局的加锁顺序、锁的持有时间要短、加上超时以便卡住的任务能大声地失败、持锁期间绝不调用未知代码,并在存在饥饿风险的地方使用公平调度。把这些规则写下来,因为新作者无法仅凭代码本身重新发现它们。

用背压为队列、池子和在途工作量设置边界

无边界的队列是一颗定时炸弹。在流量高峰下,工作到达的速度超过消化的速度,队列无限增长,内存被填满,服务以一种看起来像神秘的内存不足崩溃、而不是它实际所是的过载的方式死去。为每一个队列设边界,为每一个线程池设上限,并应用背压(backpressure):当系统已满时,向上游发出信号,让它减速或快速拒绝工作,而不是接受你根本无法完成的无限工作。根据工作负载来确定池子的大小(对 CPU 密集型工作大致按核心数来定,对 I/O 密集型工作()线程大部分时间在等待()则可以更高),并把这个边界当作一个刻意的容量决策。这与第 3.3 章的韧性模式相呼应。

在工作可以”尴尬地并行”的地方使用数据并行

有些问题可以干净地切分:对一个大数据集的每个元素施加相同的操作,元素之间互不依赖。这种数据并行是最友善的一种,因为几乎没有共享状态可供竞争,加速比可以接近核心数()map-reduce 流水线、并行数组操作和向量化数值代码都证明了这一点。即便在这里,也要尊重 Amdahl 定律:合并或归约(reduce)步骤往往是串行的,会限制你的收益,而对于小规模输入,切分本身的开销可能会占主导。当单个元素的工作量足够大、且元素之间确实相互独立时才使用它;否则,最简单的顺序版本往往既足够快、又更容易保持正确,这一点也正是第 2.9 章的构建实践所强调的。

有意识地测试和调试非确定性代码

并发 bug 是非确定性的,因此只运行一种交错情况的普通测试大多会漏掉它们。要有意地用压力测试和模糊测试(fuzz test)来攻击这个问题,在随机化的时序下运行大量任务,以抖出罕见的顺序问题。借助竞态检测器(race detector)和线程消毒器(thread sanitizer)()这类工具会对内存访问进行插桩,即使本次运行没有触发出错的交错,也能捕捉数据竞态。在你的平台支持的情况下,使用确定性模拟或受控调度器来重放特定的交错,把一个”海森堡 bug”变成可复现的 bug;并进行相应的设计,使生产环境中的挂起能够捕获线程状态和锁的持有情况,这也与第 2.15 章的调试规范相呼应。最重要的是,优先选择那些让整整一类此类 bug 变得不可能出现的设计(不可变性、消息传递、单一所有权),因为一个你无法制造出来的 bug,就是一个你永远不必调试的 bug。

权衡:利与弊

方式优点缺点
带锁的共享内存单次操作快;为人熟悉竞态、死锁与可见性 bug;对众多作者而言难以保持正确
不可变性无需同步;读取天然线程安全复制成本;对大型可变结构不便
消息传递(actor、channel)数据流显式;整类 bug 消失每条消息有开销;如果队列无边界,可能掩盖背压问题
Async/await对 I/O 密集型工作提供廉价的并发;代码看起来是顺序的对 CPU 工作没有并行加速;一个任务阻塞会拖慢其他任务
结构化并发任务生命周期清晰;错误会传播;没有泄漏的任务较新,在部分生态中可用性较低
数据并行在独立工作上接近线性加速受 Amdahl 上限约束;小规模输入时开销占主导
原子操作/无锁简单值上没有锁争用极易出现细微错误;难以评审

核心张力在于安全与原始速度之间,解决之道是先购买正确性,只在测量证明确有必要时才花费性能预算。原始的共享内存加锁每次操作最快,但每一行代码也最危险;更高层次的模型会花费一点吞吐量,换回大量的安全性与清晰度,对于由许多人共同维护的代码而言,这笔交易绝对值得。把手工调优的无锁并发留给那些经过剖析器(第 2.16 章)证明协调开销确实重要的小型热点,即使是这些热点,也要保持在一个经过充分测试的边界之内。

需要与团队讨论的问题

  1. 对于你最繁忙的服务,工作负载是 I/O 密集型还是 CPU 密集型,你的并发设计与之匹配吗? 团队经常为那些 95% 时间都花在等待数据库上的服务添加线程池,结果只获得了争用,没有获得吞吐量;或者尝试并行化一个串行部分本身就限制了任何加速比的计算。正确的设计应遵循这一判断:对等待密集型工作使用 async 或小型池,对计算密集型工作在多核之间实现真正的并行。带上能显示时间实际花在哪里的性能剖析(profile),而不是凭假设;如果大部分时间花在计算上,测量出串行部分的比例,让 Amdahl 定律告诉你上限。这个答案决定了你应该用 async、有边界的池,还是数据并行。

  2. 你的团队在任务间共享状态的默认做法是什么,它在结构上是安全的吗? 在一个大型团队中,默认做法比例外情况更重要,因为大多数代码是由不是并发专家的人编写的,他们只会照搬已有的模式。如果默认做法是用临时加的锁去保护共享可变对象,那你离一次因忘记加锁而在数月后暴露在生产环境中的竞态,只差一步之遥。如果默认做法是不可变性和消息传递,整整一类 bug 就永远不会发生,而真正需要共享内存的罕见之处也会因此显得突出,便于认真评审。讨论一下今天的新工程师会本能地采用什么做法、你们的评审能否发现一次未同步的写入,以及如何让安全路径成为更省事的那条路径。

  3. 对于在生产环境中百万分之一概率才出现一次的并发 bug,你们要如何发现、复现并修复它? 对许多团队而言,诚实的答案是”做不到”,因为这个 bug 一旦被观察就会消失,而他们的测试永远只运行一种无害的交错情况。这应该让你担忧,因为这类 bug 会悄无声息地损坏数据,侵蚀信任。谈一谈你们是否在持续集成中运行竞态检测器和线程消毒器、是否用随机化时序做压力测试,以及生产环境的可观测性能否在挂起发生的那一刻捕获线程和锁的状态。最好的团队会通过选择合适的模型,让绝大多数此类 bug 根本不可能发生,从而使残余的少数变得罕见且可控。

  4. 你的系统中哪里还存在无边界的队列或无上限的线程池,一旦流量突然暴涨十倍会发生什么? 这一点很重要,因为无边界的在途工作正是伪装成神秘的内存不足崩溃的那种故障:工作到达的速度超过消化速度,内存被填满,服务以看起来像硬件故障、而不是它实际所是的过载的方式死去。这里的权衡是真实的,因为设得太低的边界会拒绝合法流量,设得太高的边界只是推迟崩溃而不是阻止崩溃,所以这个数字是一个容量决策,而不是一个猜测。带上每一个队列和池子的清单、它当前的边界(或承认它根本没有边界)、队列填满时的背压行为,以及系统在边界处如何降级的负载测试证据。对于一个企业级机群,单个无边界队列就可能级联成整个机群的宕机;对于一个必须持续对公民可用的政府平台,以清晰错误优雅拒绝是一项服务义务,因此这个边界及其拒绝路径应当写进容量计划和操作手册,而不是留在某一位工程师的记忆里。

  5. 你的团队在使用更高层次的并发模型与手写锁之间的策略是什么,你们在哪些地方允许了例外? 默认模型决定了普通改动的安全程度,因为大多数作者不是并发专家,只会照搬已有的模式:actor、channel 与结构化并发为每个人提高了底线,而原始加锁在理论上是正确的,在实践中却是死锁的来源。这里的张力在于,更高层次的模型会带来每条消息或每个任务的一点开销,而剖析器偶尔会证明某条热路径确实需要手工调优的无锁代码,因此一刀切的禁止和放任自流一样都是错的。带上你们在安全默认值之下所做的例外清单、为每一处例外提供正当理由的剖析证据,以及每一处例外是如何被限定在经过测试的边界和一份有文档记录的加锁顺序之内的。在大型企业中,这项策略正是防止成千上万贡献者各自重新发明不安全方案的关键;在一个长期运行的政府系统中,它能让多年后的评审者理解当初为何允许一种危险的模式,并确认它是否仍然合理。

  6. 当你决定并行化一项计算时,你们如何测量串行部分的比例,由谁负责确认这个加速是真实的? 团队经常把一项计算分摊到多个核心上,并为一个剖析器根本不会认可的数字而庆祝,因为 Amdahl 定律把收益上限设定为串行比例的倒数,无论你增加多少核心都无法突破;而对小规模输入而言,拆分与合并的开销甚至可能完全抵消收益。这里的张力在于,并行会带来真实的复杂度和新的竞态面,所以问题在于测得的加速是否值得你为此承担的正确性风险。带上一份能隔离出串行部分的性能剖析、一份说明在哪些输入规模下并行确实占优的数据,以及在有代表性硬件上做的前后对比基准测试,而不是一个乐观的估计。对于要为一个庞大计算机群买单的企业而言,一份诚实的串行比例分析,直接转化为省下或浪费掉的硬件开支;对于要为一个公共系统的成本负责的政府机构而言,签字批准并行设计的人应当能在审计中拿出当初支撑该判断的测量数据。

行业视角

初创公司。 团队规模很小,也没有余力去养一位并发专家,所以要用结构而不是专家来换取正确性。选用你所用语言提供的那一个安全默认选项()I/O 密集型工作用 async/await,任何共享状态交给一个负责任务或一个 actor 来拥有()完全不要碰手工调优的加锁。支付路径上的一次更新丢失竞态,可能比错过一个功能更快把你拖垮,所以花一点额外的代码,把这类 bug 变得不可能发生,然后继续前进。

小型企业。 你们没有专职负责并发的人,所以应偏向那些替你处理好并发的平台和托管服务:一次数据库事务、一个托管队列,或一个框架的请求模型,都胜过你自己手写维护的线程。在评估一个工具时,把”这是否默认就让并发是安全的”当作一个买还是建的问题来考量,优先选择那种错误的交错顺序也不会悄悄损坏客户记录的方案。尽量把共享可变状态从你自己的代码中移出,交给一个有边界的托管服务去持有。

企业。 在众多团队之间,目标是一个能让成千上万贡献者保持安全的统一默认做法:以不可变性和消息传递为常态,优先选用更高层次的模型而非原始锁,为队列和池子设边界并配合背压,并有一份文档记录的全局加锁顺序。把这些写进工程标准,用竞态检测器和压力测试在持续集成中强制执行,并对那些经过剖析器证明合理的无锁代码例外进行治理,确保每一处都被限定在一个经过测试、经过评审的边界之内。把并发容量作为一个机群层面的问题来管理,队列边界和池子大小与测得的负载相挂钩。

政府。 在运行数十年的系统中,正确性与可审计性的优先级高于原始吞吐量。要求每一次状态迁移都被记录并可重放,这样一旦怀疑存在竞态,就能复现它并向监督机构证明修复是有效的,并且在涉及福利、安全或公共记录的决策上保留无 AI 的确定性路径。采购环节应当要求供应商披露其并发模型,以及竞态检测器和压力测试覆盖率的证据,因为一个公共系统在负载下给出的错误答案不是小麻烦,而是某位负责任的官员日后必须解释的事情。

示例

初创公司。 一个小团队上线了一个支付功能,发现账户余额在负载下偶尔会漂移几分钱。原因是并发请求处理器对同一个余额字段做了一次普通的读取-修改-写入,这是一次更新丢失竞态。他们没有到处撒锁,而是把每个账户的余额交给一个单一的负责任务,以消息的形式逐条处理借记和贷记。漂移消失了,代码也变得易于推理,他们还加了一个压力测试,并发发起成千上万笔转账,以守护这个修复。一次结构性改动,退休了一整类 bug。

企业。 一个每秒处理数万请求的高吞吐订单服务,在流量高峰期间出现周期性延迟尖峰,偶尔还会内存不足崩溃。调查发现,线程池背后有一个无边界的工作队列,一旦需求超过容量就会无限增长。团队为队列设了边界,把线程池大小限定为与核心数挂钩的一个值,并加上了背压,能快速拒绝多余负载并给出明确错误。吞吐量变得可预测,崩溃停止了,而共享缓存上的一把热点锁,只有在剖析器证明争用确实存在之后,才被替换成一个无锁结构。为众多作者提供安全默认值,只在经过测量之处才调优并发。

政府。 一个全国性的福利平台要运行数十年,即使在并发的案例更新之下,也必须产出可审计、正确的结果。团队选择以不可变性和消息传递作为统一默认做法,把每一份可变状态限定给单一所有者,并在仍需要使用锁的地方强制一个全局加锁顺序,所有这些都写入了工程标准。他们在流水线中运行线程消毒器和随机化压力测试,并进行相应设计,使每一次状态迁移都被记录并可为监督重放,这让他们能够在怀疑存在罕见交错问题时复现并证明修复有效。正确性与可审计性被当作一等需求对待,而不是性能之外的事后考量。

商业理由:动机、投资回报率与总拥有成本

规范化并发所带来的回报,体现为从未发生的事故。一次生产环境竞态就可能损坏数千条记录,其代价既包括寻找一个”一被观察就消失”的 bug 所耗费的工程时间,也包括修复错误数据、通知受影响用户、重建信任这一更大的代价。这类缺陷是诊断成本最高的一类,恰恰是因为它们是非确定性的,因此追查一个海森堡 bug 的工作量,可能远远超过一开始就选择一个安全模型的工作量。

收益的另一面体现为吞吐量和成本。恰当地调整并发规模,能让一个服务在相同硬件上处理远大得多的负载,这对一个大型机群而言是持续的节省;而背压与有边界的队列,能防止一次流量高峰级联成一起公共事故。总拥有成本是有限的,且主要是文化层面的:你投资于一种统一风格(不可变性、消息传递、结构化并发),投资于工具(竞态检测器、线程消毒器、持续集成中的压力测试框架),以及投资于把加锁顺序和边界设定写进标准。另一种选择是一个正确性永远取决于每一位作者都是永久专家的代码库,这是任何在成长中的团队都无法维系的。向领导层用他们熟悉的单位来阐述这个理由:把一次被阻止的竞态换算成避免的数据损坏事故,把背压换算成被阻止的宕机,把一个安全默认值换算成节省下来的新人上手时间。

反模式与陷阱

  • 为 I/O 密集型工作添加线程以求提速。 在一个等待密集型的服务上增加更多线程,换来的是争用,而不是吞吐量。
  • 到处都是共享可变状态。 任何线程都能修改任何对象,让正确性变成一件没有任何评审者能够核实的运气问题。
  • 无边界的队列和池子。 一次波动会让队列不断增长直到内存耗尽;崩溃看起来很神秘,实际上不过是普通的过载。
  • 没有全局顺序的临时加锁。 代码库不同位置以不同顺序获取锁,会在负载下死锁。
  • 假定代码按书写顺序运行。 忽视内存模型,导致一个可见性 bug 让线程在一个陈旧的值上空转。
  • 手搓的无锁”小聪明”。 自制的无锁方案几乎总是存在细微的错误,也几乎不可能被评审清楚。
  • 只测试happy path的交错情况。 确定性测试全部通过,而百万分之一的那种顺序却在生产环境中损坏了数据。
  • 在持锁期间调用未知代码。 一个会阻塞或重入的回调,会把一个临界区变成一次死锁。

成熟度模型

  • 第 1 级,启动: 并发是临时且被动的。线程和锁凭直觉添加,共享可变状态无处不在,队列没有边界。竞态条件以无法复现、无人能诊断的生产事故形式出现,也没有任何工具去捕捉它们。
  • 第 2 级,发展: 一些团队已经学会了基本做法:他们更谨慎地使用锁,并为最明显的队列设置边界。对竞态和死锁有非正式的认知,少数关键路径会得到额外的审视。各团队之间做法不一致,测试大多仍只覆盖单一交错情况,安全模式存在于个人经验中,而不是文字记录里。
  • 第 3 级,标准化: 组织有一份全组织范围强制执行、成文的统一风格:以不可变性和消息传递为默认,优先选用更高层次的模型而非原始锁,为队列和池子设边界并配合背压,以及一份成文的全局加锁顺序。竞态检测器和压力测试在持续集成中运行,并发方案的选择遵循工作负载是 I/O 密集型还是 CPU 密集型这一判断。
  • 第 4 级,管理: 组织依据基线来测量和控制自身的并发状态。它追踪各服务的竞态检测器和线程消毒器覆盖率,把队列深度、锁等待时间、池子饱和度和拒绝率记录为受监控的指标,并对降级曲线进行负载测试,使每一个边界都是数据支撑的容量决策。并发事故被统计并追踪趋势,并行化工作负载的串行比例会与实际取得的加速比进行对照,新设计的通过或不通过取决于这些证据,而不是直觉。
  • 第 5 级,编排: 安全的并发是每一位作者阻力最小的路径,实践在整个组织内被持续改进并整合。整整几类 bug 在结构上已经变得不可能出现,热点只在剖析证明确有必要之处才被调优,确定性重放让残余的极少数 bug 也变得可复现。正确性与可审计性是被持续维护的属性,容量边界随观测到的负载动态调整,标准随平台和工作负载的变化而演进。

讨论思路

  1. 如果你今天审计一下最繁忙的服务,有多少状态是共享且可变的,其中又有多少共享是真正必要的?
  2. 当有人需要两个任务协调时,你的团队默认会怎么做,你更希望默认答案是不可变性还是消息传递?
  3. 无边界的队列或无上限的池子还藏在你系统的哪些地方,一旦流量突然暴涨十倍,会发生什么?
  4. 你们的持续集成流程中是否包含竞态检测器或线程消毒器,上一次它在进入生产环境之前捕捉到问题是什么时候?
  5. 对于你并行化程度最高的工作负载,串行部分的比例是多少,Amdahl 定律是否限制了你实际追求的那个加速比?
  6. 你的团队能否按需复现一个百万分之一才出现一次的交错 bug,要做到这一点还需要什么?

关键要点

  • 并发把程序组织成独立的任务;并行则同时执行这些任务。在添加线程之前,先决定你需要的是哪一个。
  • 共享可变状态几乎是所有并发 bug 的根源;对众多作者而言,优先把不可变性和消息传递作为安全的默认选择。
  • 在使用手写锁之前,先考虑更高层次的模型(actor、channel、结构化并发、async/await)()手写锁在理论上正确,在实践中却很危险。
  • 理解原子性、可见性以及你所用的内存模型;使用恰当的原语,锁的持有时间要短,并强制一个全局加锁顺序,以避免死锁、活锁和饥饿。
  • 为每一个队列和池子设置边界并应用背压,让一次波动优雅降级,而不是崩溃(第 3.3 章)。
  • 有意地用竞态检测器、压力测试和重放(第 2.15 章)去测试各种交错情况,并在做并行化时尊重 Amdahl 定律(第 2.16 章)。
  • 对企业而言,这关乎吞吐量与被阻止的事故;对政府而言,这关乎长期运行系统中的正确性与可审计性。

参考资料与延伸阅读

  • Brian Goetz et al., Java Concurrency in Practice(原子性、可见性、内存模型与安全发布)。
  • Herb Sutter, “The Free Lunch Is Over”(为什么在时钟频率见顶之后,软件必须拥抱并发)。
  • Leslie Lamport, “Time, Clocks, and the Ordering of Events in a Distributed System”(顺序问题与并发推理的基础)。
  • C. A. R. Hoare, “Communicating Sequential Processes”(Communications of the ACM, 1978):channel 背后的 CSP 模型。
  • Carl Hewitt, Peter Bishop, and Richard Steiger, “A Universal Modular Actor Formalism for Artificial Intelligence”(actor 模型的起源)。
  • Edsger W. Dijkstra, “Cooperating Sequential Processes”(信号量、互斥与死锁问题)。
  • Maurice Herlihy and Nir Shavit, The Art of Multiprocessor Programming(锁、原子操作与无锁数据结构)。
  • Nathaniel J. Smith, “Notes on Structured Concurrency, or: Go Statement Considered Harmful”(结构化并发的论证)。
  • Martin Kleppmann, Designing Data-Intensive Applications(内存与分布式系统交汇处的并发与一致性)。
  • Gene M. Amdahl, “Validity of the Single Processor Approach to Achieving Large-Scale Computing Capabilities”(1967):Amdahl 定律的起源。