
在现代计算领域,“并发”和“并行”这两个术语经常被互换使用,但它们代表了根本不同的概念,这对于构建高效和可扩展的软件至关重要。理解这种差异不再是一项学术活动,而是任何使用当今多核处理器的开发人员的必需。本文通过对这两个概念进行清晰而全面的区分,来解决常见的混淆,填补可能导致软件设计缺陷和性能瓶颈的知识鸿沟。
本文的结构旨在引导您从基础理论走向实际应用。第一章“原理与机制”将解构核心思想,通过类比来解释并发如何通过时间分片等技术在单个核心上提供同时进行的错觉,而并行则在多个核心上提供真正的同时执行。我们还将探讨从并发世界过渡到并行世界时出现的固有局限性和新型错误。随后,“应用与跨学科联系”一章将展示这些原理不仅是理论性的,而且是从您笔记本电脑上的操作系统到驱动云的庞大分布式系统等一切事物的架构基石,揭示了整个计算领域中一套统一的挑战和解决方案。
要真正掌握现代计算的世界,我们必须首先理解一个位于其核心的区别,这个区别既微妙又深刻:并发与并行之间的差异。乍一看,它们似乎是“一次做很多事”的同义词,但它们描述的是两个根本不同的概念。为了理清它们,让我们不从计算机开始,而是从一个厨房说起。
想象一位厨师试图准备一顿复杂的饭菜。他切几分钟蔬菜,然后转身查看正在煨的酱汁,接着快速搅拌一份调味汁,然后又回去切菜。在任何一个瞬间,这位厨师只在做一个动作。然而,在一小时的过程中,所有三个任务——切蔬菜、熬酱汁和调味汁——都取得了进展并最终完成。这就是并发:一种组织任务的方式,以便在重叠的时间段内管理并推进多个任务。它是关于处理多件事情。
现在,想象我们雇了另外两位厨师。一位可以专门负责切蔬菜,另一位可以照看酱汁,第三位可以准备调味汁。现在,所有三个任务都在同一时刻发生。这就是并行:多个任务的同时执行。它是关于做多件事情。
并发是一个逻辑和结构上的问题。并行是硬件和执行上的特性。一个厨师可以是并发的,但你需要多个厨师才能实现并行。计算机也是如此。
让我们从一台简单的计算机开始我们的旅程,一台只有一个处理核心的计算机——一个“厨师”。这台机器如何能看似同时运行你的网页浏览器、音乐播放器和操作系统呢?它不能。这台计算机,就像我们那位单打独斗的厨师一样,是一位技艺高超的魔术师。这个魔术被称为时间分片。
操作系统 (OS) 扮演着厨房经理的角色。它给每个正在运行的程序或线程分配一小片CPU的注意力——一个时间量,也许只有几毫秒长。当时间到了,OS会迅速冻结当前线程,保存其状态,并切换到下一个线程。这个过程发生得如此之快,以至于在我们的感知中,一切似乎都在平稳且同时地运行。这种任务的快速交错执行就是单核上并发的本质。
但这种“杂耍般的切换”并非没有代价。每当OS在线程之间切换(一次上下文切换)时,它都会消耗少量本可用于实际工作的CPU时间。如果你只有一个线程在运行,它会得到整个CPU。如果你运行两个计算密集型线程,OS将在它们之间进行时间分片。没有一个线程能在一半的时间内完成;实际上,运行这两个线程的总时间会比它们各自运行时间的总和还要略长一些,因为所有切换都带来了开销。增加更多的线程并不能神奇地创造更多的处理能力;它只是将单个核心的能力分散到更多的任务上。这是单核系统的一个基本事实:并发提供了同时进行的错觉,但它不能为CPU密集型工作带来加速。
这种交错执行不仅适用于用户程序。即使是单个线程也可能被硬件本身中断,例如,当一个网络数据包到达或鼠标被移动时。OS必须立即暂停正在运行的线程,以执行一段称为中断服务程序 (ISR)的特殊代码来处理该事件,然后恢复原始线程。这表明,即使在最底层,单个核心的时间也是由不同的、交错的执行流编织而成的挂毯。
现在,让我们升级我们的厨房。我们安装一个拥有多个核心的现代CPU——多个厨师。我们第一次拥有了实现真正并行所需的硬件。
我们如何确定这真的有所不同?我们可以设计一个实验。让我们取大量的CPU密集型线程。首先,我们将强制它们全部在单个核心上运行(阶段1)。如果我们绘制每个线程随时间的进度图,我们会看到一个阶梯状模式:一个线程取得进展,然后停止,另一个开始。它们的进展是交错的。这是纯粹的并发。接下来,我们将相同的线程释放到所有可用的核心上(阶段2)。如果我们现在看进度图,我们会看到多个线程在完全相同的瞬间取得进展。它们的进度线将同时上升。这是并行的直接、无可否认的标志。
但仅仅拥有多个核心并不能保证我们的程序会运行得更快。我们给了厨师们各自的工作台,但如果食谱本身有一个步骤一次只能由一个厨师来做呢?
想象一个食谱,其中最后关键的一步是品尝汤并调整调味。这个任务只能由一个人,即主厨来执行。无论你有两个还是二十个厨师来切蔬菜,总时间将永远受限于这个单一、串行的品尝步骤需要多长时间。
这个基本见解被阿姆达尔定律所捕捉。它告诉我们,任何程序的最大加速比受限于其工作中固有串行部分的比例——即无法并行的那一部分。在计算中,串行化的一个常见来源是临界区:一段访问共享资源(如全局计数器或日志文件)的代码,必须由锁或互斥锁保护以防止数据损坏。一次只有一个线程可以持有锁并执行临界区。
如果一个任务将其时间的用于可并行化的工作,而用于一个串行临界区,那么即使有无限数量的核心,该程序的速度也永远不会超过其原始速度的倍。串行部分成为了一个无法打破的瓶颈。在某些情况下,管理并发的开销——持续的上下文切换和线程在锁上阻塞——甚至可能使单核上的多线程程序比一个简单的顺序版本运行得更慢。
一个引人注目的现实世界例子是某些编程语言(如CPython)中的全局解释器锁 (GIL)。GIL是一个单一的、进程范围的锁,用于保护该语言的内部状态。即使你有16个核心并运行16个CPU密集型线程,也只有当前持有GIL的线程才能真正执行代码。OS可能会将其他15个线程调度到其他15个核心上,但它们都会被卡住,等待GIL。结果是只有并发而没有并行,对于CPU密集型任务没有任何加速。要在这种环境中实现真正的并行,人们通常必须诉诸于使用多个进程,每个进程都有自己的解释器和自己的GIL,这就像建立完全独立的厨房一样。
从单核并发世界进入多核并行世界,不仅仅是速度的提升。这就像踏入了一个物理定律似乎略有不同的新维度,它引入了全新类别的、微妙而令人抓狂的问题。
想象两位厨师,Alice和Bob,在同一个台面上工作。Alice正在为她的汤切洋葱,而Bob正在为他的沙拉切西红柿。他们正在用不同的食材做不同的任务。但因为他们共享同一个物理台面空间(在计算机术语中称为缓存行),每当Alice的刀砸下来时,她都会颠簸到Bob的西红柿。Bob不得不停下来重新整理它们。然后Bob开始切片,他又打扰了Alice整齐的洋葱堆。尽管他们没有共享食材,但他们互相干扰,减慢了彼此的速度。
这就是伪共享。两个不同核心上的两个线程可能正在更新完全独立的变量,比如counter_A和counter_B。但如果这些变量恰好在内存中相邻,它们可能会落入同一个缓存行中。硬件的缓存一致性协议,旨在保持内存一致,将整个缓存行视为一个单元。当Alice的核心写入counter_A时,该协议会使Bob核心中的整个缓存行失效。当Bob的核心随后需要写入counter_B时,它发现它的副本无效,必须重新获取整个缓存行,从而导致执行停顿。结果是并行加速比的急剧下降,而且非常难以诊断。解决方案?给每个厨师更多的空间。通过策略性地向我们的数据结构中添加填充,我们可以确保每个线程的关键数据驻留在其自己的私有缓存行上,从而消除干扰。
一个更深层次的奇特现象源于这样一个事实:在现代多核芯片上,信息并非瞬时传播。每个核心都有自己的私有“记事本”,一个存储缓冲区,它在其中记下打算对主内存进行的写入操作。在将这个“笔记”正式发布给所有其他核心之前,它可能会继续执行其他指令。
这种延迟为奇异的结果创造了一个窗口。考虑以下场景:核心1上的线程A将值1写入变量x,然后读取y。同时,核心2上的线程B将1写入y,然后读取x。直观上,两个线程都读到0似乎是不可能的。其中一个写入必须“先”发生,对吗?在并行世界里并非如此。核心1完全有可能在核心2写入y的消息到达之前执行其对y的读取。同时,核心2可以在核心1写入x的消息到达之前读取x。结果是:两个线程都读到0。这就是数据竞争,一种看似违背逻辑但却是并行硬件物理现实的直接后果的情况。在单核并发世界中,这几乎不可能观察到,因为两个线程都通过同一个核心及其统一的缓存“看”世界。并行暴露了这些深层的硬件行为,而驯服它们的唯一方法是使用称为内存屏障的特殊指令,强制一个核心在继续执行前发布其所有待处理的写入。
理解这些原则不仅仅是一项学术活动;它决定了我们如何设计高效且正确的软件。
选择锁: 当一个线程需要等待一个资源时,它应该使用自旋锁(在紧密循环中忙等待)还是互斥锁(进入睡眠状态,让OS唤醒它)?答案取决于你的环境。如果你只有一个核心,自旋锁是个糟糕的主意;自旋的线程正在窃取它所等待的那个线程宝贵的CPU时间!最好是睡眠。但在多核系统上,如果锁被持有的时间非常短,一个线程在其专用核心上自旋可能比支付进入睡眠和被唤醒的高昂成本要快得多。这个选择是并发和并行之间的一个直接权衡。
确定线程池的大小: 一个Web服务器应该有多少个线程?如果任务纯粹是CPU密集型的,答案很简单:每个核心一个线程()。再多就没有任何价值了。但如果任务还涉及等待网络或数据库(I/O)呢?当一个线程被阻塞等待时,它的核心处于空闲状态。为了让所有核心都保持忙碌,我们需要的线程数比核心数多。理想的线程数与等待时间与计算时间的比率有关,这个值由公式捕捉,其中是等待时间,是计算时间。但这还不是全部!我们还需要足够的线程来处理涌入的请求,而不会让用户等待太久,这个数字由利特尔定律决定。最终的、最优的线程池大小是一个美妙的综合体,一个单一的数字,它平衡了并行的硬件限制和并发的延迟需求。
并发和并行不仅仅是技术术语;它们是组织计算的两种基本范式。并发是魔术师的艺术,在空中抛接许多球。并行是集体的原始力量,众人拾柴火焰高。一个大师级的程序员,就像一个大师级的厨师,必须两者都懂:如何并发地组织工作,以及在可用时如何利用并行,同时还要驾驭当许多事情真正同时发生时出现的微妙复杂性。
掌握了并发和并行之间的本质区别后,我们现在可以开始一段旅程,去看看这些概念在实际中的应用。你会发现这不仅仅是一项学术活动;这些思想正是数字世界的构建师。它们决定了你笔记本电脑的速度,互联网的响应能力,以及解码宇宙奥秘的超级计算机的力量。物理学的美在于少数几个基本原理如何能解释广泛的现象,这里也是如此。我们即将看到,交错执行和同时执行之间的简单舞蹈如何塑造了我们使用的几乎每一项技术。
让我们从最中心的地方开始:操作系统(OS),这个管理着机器所有资源的幽灵。其最关键的工作之一是调度——决定众多竞争任务中哪一个可以使用处理器。
想象一个农场上的一台水泵,必须灌溉几块田地。这是一个完美的类比,单核处理器是泵,许多程序是田地。水泵一次只能给一块田地浇水,但通过在它们之间切换,它可以让所有田地都取得进展。这是最纯粹形式的并发。农场经理必须决定一个时间片,即在切换前给每块田地浇水多长时间。如果非常大,水泵效率很高,因为它大部分时间都在抽水,很少时间花在切换的开销上。但这意味着一些田地可能要等很长时间才能得到第一滴水!相反,一个非常小的让系统感觉响应迅速——每块田地都很快得到水——但如此多的时间浪费在切换上,以至于输送的水的总吞吐量急剧下降。这是每个OS设计者都面临的吞吐量和延迟之间的基本权衡。通过增加第二台水泵,农场可以同时为两块田地浇水——这是真正的并行,它从根本上增加了系统的容量。
但是OS如何决定接下来运行哪个任务,特别是当一些任务比其他任务更重要时?考虑一个紧急调度中心,它有有限数量的救护车(并行单元)和并发涌入的严重程度不同的来电。一个简单的规则——“总是派往最严重的呼叫”——似乎合乎逻辑。但如果心脏病发作的电话不断打来,一个断腿的电话会怎样?断腿的呼叫可能永远等待,这种现象称为饿死。一个优美而简单的解决方案,被用于许多实时操作系统中,是“老化”。当一个任务等待时,它的优先级会慢慢增加。最终,即使是优先级最低的任务也会因为等待了太长时间,其优先级会上升到超过所有其他任务,从而保证它最终会被服务。这个优雅的机制确保了公平性并防止了饿死,这是可靠系统的一个关键属性。
这种设计理念深入到OS的内部。当一个程序中的多个线程都需要向OS请求内存时,一个天真的设计可能会用一个单一的全局锁来保护内存分配器的数据结构。在多核机器上,这是一场灾难!即使有8个或16个核心准备工作,一次也只有一个核心可以分配内存;其他核心则在空闲等待。并行的硬件被一个软件瓶颈搞得毫无用处。解决方案是从一个集中的、并发的模型转向一个分散的、并行的模型。高性能分配器给每个线程一小块私有的内存块缓存。大多数时候,线程可以从它们的本地缓存中获取内存而无需等待。只有当缓存用尽时,它们才需要去全局分配器那里批量补充。这极大地减少了对全局锁的争用,释放了并行硬件的力量。
并发和并行的原则对于运行在OS之上的软件同样至关重要。想一想从源代码构建一个大型应用程序的过程,这是软件开发人员每天都要做的一项任务。第一步,编译数千个独立的文件,是“易于并行”的——我们可以投入几十个CPU核心来处理它,它会更快完成。但最后一步,将所有编译好的部分链接成一个单一的可执行文件,通常是一个固有的串行任务。无论你有多少核心,总的构建时间将永远受限于那一个最终的、顺序步骤的速度。这是阿姆达尔定律的一个深刻而实际的展示:一个程序的加速比最终受限于其串行部分。
我们在文件系统和数据库中也看到了同样的模式。为了防止因崩溃而导致数据丢失,许多系统使用日志记录:在更改数据之前,它们首先将关于预期更改的说明写入日志或“journal”。将这个journal提交到磁盘的行为通常是一个串行瓶颈,因为每次提交都有固定的开销。一个提高性能的巧妙技巧是批处理。系统不是单独提交每一个微小的写入,而是收集一批写入并一次性提交它们。这将固定开销分摊到许多操作上,从而显著提高吞吐量。代价是什么?延迟。你个人的写入现在必须等待批次的其他部分组装完毕才能被保存,这是我们一次又一次遇到的经典权衡。
有时,这些串行瓶颈并非问题所固有,而是我们自己软件设计的产物。想象两个程序更新一个共享的日志文件。如果我们使用一个保护整个文件的单一、“粗粒度”锁,那么一次只有一个程序可以写入,即使它们正在写入文件的完全不同部分!我们创造了一个本不该存在的瓶颈。解决方案是使用“细粒度”锁定,其中锁只保护正在被修改的特定记录或区域。这允许两个程序同时写入文件的不同部分,将人为的并发转变为真正的并行。
再放大来看,同样的原则也支配着地球上最大型计算机系统的行为。现代云应用由许多小的、独立的“微服务”构建而成。考虑一个请求流经前端服务、中间服务再到后端服务的管道。每个服务都有一定数量的副本,这决定了其并行处理能力。如果流量激增超出了中间服务的能力,请求队列就开始形成。这不仅增加了延迟,还对前端施加了“背压”,而前端本身可能对其可以“在途”处理的请求数量有并发限制。理解这种相互作用——在系统中流动的请求的并发性与服务的并行能力之间——是构建可扩展、有弹性的分布式系统,使其在压力下不会崩溃的关键。
在高性能计算(HPC)领域,这些思想被推向了它们的绝对极限。在一个大规模的金融模拟中,你可能会并行运行数百万个独立的蒙特卡洛路径。最后一步是聚合结果——例如,通过将它们求和。天真的方法是,成千上万的核心中的每一个都获取一个锁来将其结果添加到一个单一的全局总和中,这会造成巨大的交通堵塞。一种优越得多的方法是并行树归约。核心被配对起来对它们的本地结果求和。然后,这些结果对再被求和,依此类推,直到最终的总和在与核心数量的对数成正比的步数内计算出来。这是一个完全避免了串行瓶颈的优美算法。
对于真正复杂的问题,比如模拟飞机机翼的流体动力学,科学家现在使用性能可移植性框架。这些是编程模型,允许他们为其并行算法编写单一的、抽象的描述。然后,一个复杂的编译器将这个抽象描述翻译成针对不同类型并行硬件的高度优化代码——无论是一个拥有少数强大核心的CPU,还是一个拥有数千个较简单核心的GPU。这可能涉及自动改变内存中的数据布局或填充数据结构,以确保内存访问针对特定架构是完美“合并”的,这证明了抽象算法与物理硬件之间深刻而复杂的联系。
最后,令人谦卑的是,要记住并行不仅仅是计算机科学的一个抽象概念,它从根本上受到物理定律的约束。我们可能想象一个拥有个核心的处理器给了我们路并行。但考虑一个功率预算至上的深空探测器。每个活动的CPU核心都从太阳能电池板或放射性同位素发电机中消耗宝贵的瓦特。系统可能有12个物理核心,但如果瞬时功率预算只允许5个同时活动,那么系统的真正并行度就被限制在5,无论芯片上蚀刻了多少核心。即使有8个重要任务要运行,OS也必须在5个可用的“功率插槽”上并发地调度它们。这是一个惊人的提醒,我们的数字机器是物理对象,它们的最终性能受能量和热量等资源支配,而不仅仅是逻辑门和时钟周期。
从在单个核心上调度任务到协调全球服务器网络,并发和并行之间的对话无处不在。它存在于吞吐量和延迟之间的权衡中,存在于与饿死和瓶颈的斗争中,也存在于设计能够利用并行硬件力量的软件的持续努力中。理解这场对话,就是理解使我们的现代世界成为可能的无形工程。