try ai
科普
编辑
分享
反馈
  • 并发

并发

SciencePedia玻尔百科
核心要点
  • 并发是在重叠的时间段内管理多个任务的艺术,而并行是在同一时刻执行多个任务的行为。
  • 单个CPU核心可以通过任务切换(交错执行)实现并发,制造出同时处理的假象,但真正的并行需要多个核心。
  • 软件瓶颈,如共享资源锁(例如Python的GIL)和算法的串行部分(阿姆达尔定律),会限制并行硬件带来的益处。
  • 并发系统会引入数据竞争、死锁和优先级反转等复杂问题,需要精心的设计和同步机制来避免。
  • 架构选择,如事件驱动模型与多线程模型,对不同硬件环境下的性能和可伸缩性有着深远的影响。

引言

在现代计算的词汇中,很少有术语像​​并发​​(concurrency)和​​并行​​(parallelism)这样基础,却又如此频繁地被误解。它们是驱动我们手机上响应迅速的应用、连接全球的强大网络服务器以及解开科学之谜的超级计算机的无形引擎。尽管这两个词经常被互换使用,但它们代表了关于工作如何完成的两个截然不同且至关重要的概念。将两者混为一谈,就好比将一位杂耍大师与一队动作一致的体操运动员混淆;两者都令人印象深刻,但他们的方法和局限性却截然不同。本文旨在揭开这些概念的神秘面纱,理清“同时做很多事”的假象与现实之间的区别。

首先,在“​​原理与机制​​”一章中,我们将剖析其核心思想。通过简单的类比和技术实例,我们将定义并发和并行的真正含义,探索操作系统调度器的角色、任务切换的本质,以及并发系统中出现的风险,如死锁和数据竞争。然后,在“​​应用与跨学科联系​​”一章中,我们将看到这些原理在实践中的应用。我们将研究并发与并行之间的架构选择如何影响网络服务器的性能、用户界面的响应性以及复杂计算流水线的可伸缩性,从而揭示对这种二元性的深刻理解对于构建高效、稳健的现代软件至关重要。

原理与机制

要真正理解数字世界,我们必须掌握其中两个最基本却又常常被混淆的概念:​​并发​​(concurrency)和​​并行​​(parallelism)。乍一看,它们似乎是计算机同时处理多项事务的同义词。但事实远比这更微妙、更精妙。为了理清它们,让我们暂时离开硅的世界,走进一间厨房。

一个厨师与多道菜的故事:并发的本质

想象一位厨师要准备一顿饭。一个简单的方法是,从头到尾做完一道菜,再去碰下一道。切菜、炒肉、装盘。然后,为下一道菜重新开始。这就是​​顺序执行​​(sequential execution)。它简单明了,但效率极低。当肉在烤箱里烤的时候呢?厨师只是站在那里等待。

一个聪明的厨师绝不会这样工作。当肉在烤箱里烤时(这是一个不需要厨师积极关注的操作),他们会开始为沙拉切菜。他们可能会烧上一壶水,在水加热的时候,准备一份酱汁。厨师仍然只有一个人,在任何一个瞬间只能执行一个动作——要么是切,要么是搅,要么是装盘。然而,通过巧妙地在不同任务之间切换,他们在同一时间段内推进了多道菜的进度。多道菜同时“在进展中”。

这正是​​并发​​的本质:在重叠的时间段内管理多个任务。这是一门杂耍的艺术。

现在,让我们回到计算机。一个中央处理器(CPU)核心就像我们那位单身厨师。它一次只能执行一条指令。考虑一个单CPU核心的简单网络服务器,它正在处理两个客户端的请求。一个简单的、顺序执行的服务器会完全处理完第一个客户端的请求,然后再开始处理第二个。如果第一个请求需要等待 101010 毫秒的网络响应——我们称之为“烤箱烤肉”的时刻——CPU就会闲置。如果每个请求总共耗时 151515 毫秒(555 毫秒的CPU工作和 101010 毫秒的等待),那么两个请求将耗时 303030 毫秒。

然而,一个并发服务器的运作方式就像我们聪明的厨师。它为第一个请求执行初始的CPU工作,然后发出网络I/O调用。它不会等待,而是立即切换到第二个请求,并在第一个请求的网络操作进行期间执行第二个请求的CPU工作。通过将一个任务的等待时间与另一个任务的计算时间重叠,完成两个请求的总时间可以显著减少——在这样的场景下,可能短至 181818 毫秒。这种收益并非来自同时做两件事,而是来自智能地交错执行步骤。在任何时刻,都没有两条CPU指令被同时执行。这就是并发,而非并行。

杂耍大师:调度器与速度的幻觉

这种神奇的杂耍——即任务切换——究竟是如何发生的?在计算领域,主要有两种哲学。一种是​​协作式并发​​(cooperative concurrency),即每个任务自愿放弃控制权。一个使用​​协程​​(coroutines)的程序可能会说:“我启动了一个耗时长的I/O操作,所以现在我会把CPU让给别人。当我的数据准备好时再唤醒我。”。

在现代操作系统中,更常见的方法是​​抢占式并发​​(preemptive concurrency)。在这里,一位杂耍大师——​​操作系统调度器​​(OS scheduler)——负责主导。想象一下,三个程序都要求在我们的单CPU核心上运行。调度器带着秒表介入。它可能让进程 P1P_1P1​ 运行一小段时间(一个​​时间片​​),然后强制停止它,保存其状态,再让进程 P2P_2P2​ 运行,接着是 P3P_3P3​,然后又回到 P1P_1P1​。这个过程发生得非常快——每秒成千上万次甚至数百万次——以至于在人类观察者看来,就好像所有三个程序都在同时运行。这种由调度器管理的快速交错执行,是并发的一种强大形式。

有时,抢占甚至更具戏剧性。来自硬件设备(如鼠标点击或传入的网络数据)的​​中断​​(interrupt)就像一个紧急命令。操作系统立即停止CPU正在做的任何事情,处理该中断(在一个称为​​ISR​​的特殊例程中),然后恢复原来的任务。用户程序和中断处理程序以交错的方式取得进展,这是一个明显的并发案例,即使在最简单的单核机器上也是如此。

我们甚至可以量化这种并发的“深度”。在一个处理大量I/O请求的事件驱动系统中,我们可以测量同时“在进行中”——即已到达但尚未完成——的请求的平均数量。即使在单个核心上,这个“并发级别”也可能是一个远大于一的数字,反映了系统处理许多重叠任务的能力。

增加厨师:并行的黎明

尽管单核并发非常巧妙,但它终究是一种幻觉——一种制造出同时进展假象的精湛杂耍。要实现真正的同时执行,别无他法:你需要更多的厨师。

这就是​​并行​​(parallelism)。如果你有一台拥有 M=4M=4M=4 个CPU核心的机器,你就有了四个“工作站”。你的系统可以在完全相同的时刻执行四个不同的指令流。四个厨师可以同时切菜。这不是交错执行,而是真正的同时工作。

我们可以设计一个实验,以最纯粹的形式观察这种差异。想象一下,我们有 N=100N=100N=100 个CPU密集型任务要在一台 M=8M=8M=8 核的机器上运行。

  • ​​阶段1(纯并发):​​ 我们强制所有 100100100 个线程在单个核心上运行。我们会观察到这个核心100%繁忙,通过时间分片来调度这些线程。如果我们能够跟踪每个线程完成的工作,我们会看到它们的进度图表是一系列交错的步骤:当一个线程在推进时,其他99个线程是暂停的。
  • ​​阶段2(并发与并行):​​ 现在,我们将这些线程释放到所有 888 个核心上。操作系统调度器会分配工作。这时,我们会看到所有 888 个核心都在以100%的利用率运行。进度图表将揭示其中的奥秘:在任何给定的时刻,我们都会看到 888 个不同的线程在同时取得进展。

在阶段2中,完成工作的总时间将大大减少。这种加速就是并行的回报。并行是通过使用更多资源在相同时间内完成更多工作。并发是关于如何组织你的工作,以便能够高效地在任务之间切换,尤其是在隐藏等待时间方面。

瓶颈之殇:当并行不再并行

所以,你买了一颗闪亮的新8核处理器。这是否意味着每个程序都会运行快8倍?可惜,世界并非如此简单。就像一个拥有8名厨师的厨房,如果他们都需要使用同一个特殊的烤箱,厨房就会陷入停顿一样,并行硬件也可能被软件中的串行瓶颈所击败。

一个典型的例子是在某些编程语言(如Python)中存在的​​全局解释器锁(GIL)​​。想象一下我们厨房里有一条规则,一次只能有一位厨师查看主食谱。这条规则就是GIL。即使有8位厨师(核心),如果主要任务是“阅读并执行食谱”(运行CPU密集型代码),他们也被迫一次只能做一个人。一位厨师拿着书(GIL),而其他七位在等待。程序表现出并发性——厨师们轮流使用书——但其核心计算未能实现并行。这就是为什么在两个线程上运行一个纯CPU密集型的Python程序,通常不会比单线程快的原因。获得真正并行的唯一方法是给每个厨师自己的厨房和自己的食谱副本——也就是说,使用多进程而不是多线程。

这个原则适用于任何共享资源。想象一下,在一台8核机器上的两个进程都需要写入同一个日志文件。如果它们用一个单一的、粗粒度的“文件级”锁来保护文件,就制造了一个串行瓶颈。一次只有一个进程能持有锁并进行写入。无论你投入多少核心,系统的写入吞吐量都受限于单个顺序写入者的能力。解决方案是什么?使用更细粒度的锁,比如允许每个进程同时写入文件的不同部分。这消除了瓶颈,并释放了并行I/O的潜力。

杂耍的风险:竞争与反转

并发是强大的,但它也引入了复杂性和潜在的、难以诊断的奇怪错误。杂耍者有时会失手。

其中最臭名昭著的一个是​​优先级反转​​(priority inversion)。设想一个高优先级任务 (HHH)、一个中优先级任务 (MMM) 和一个低优先级任务 (LLL) 在单个核心上运行。假设 HHH 需要一个 LLL 正在使用的资源。HHH 会阻塞,等待 LLL。这很正常。但是,如果在 LLL 完成并释放资源之前,MMM 准备好运行了呢?由于 MMM 的优先级高于 LLL,调度器会抢占 LLL 并运行 MMM。结果是灾难性的:系统中最高优先级的任务 (HHH) 被卡住,等待一个中优先级任务 (MMM) 完成,而这个任务与它并无直接关联!这是一种危险的并发病症,曾在现实世界的系统中导致任务失败。幸运的是,有像​​优先级继承​​(priority inheritance)这样的解决方案,这是一种临时将 LLL 的优先级提升到与 HHH 相同级别的协议,从而防止 MMM 的干扰。

一个更深层次、更令人费解的问题源于现代硬件的本质。这就是​​数据竞争​​(data race)。在多核系统上,每个核心都有自己的本地缓存和“存储缓冲区”——一种私有工作区。当一个核心向内存写入一个值时,它可能首先将该值放入这个私有缓冲区。这个值要经过一段短暂但非零的时间才能对其他核心可见。这可能导致一些看似违背逻辑的情况。

考虑一个简单的测试:在核心A上,我们向位置 xxx 写入 111,然后读取位置 yyy。在核心B上,我们同时向位置 yyy 写入 111 并读取 xxx。你可能会认为,两次读取不可能都看到旧值 000。其中一次写入必然“先”发生。但由于存储缓冲区的存在,核心A读取 yyy 的操作完全有可能发生在核心B对 yyy 的写入变得可见之前,同时核心B读取 xxx 的操作也可能发生在核心A对 xxx 的写入变得可见之前。两个线程都看到了陈旧的数据!。这就是数据竞争,一个由并行硬件微小延迟催生的机器中的幽灵。解决方案是使用显式的同步机制,如​​内存屏障​​(memory fences),这些指令告诉硬件:“在我之前的所有写入操作对所有地方都可见之前,不要继续执行。”

并发和并行不仅仅是抽象的计算机科学术语。它们是支撑我们整个数字基础设施的两大支柱,从你口袋里的手机到为互联网提供动力的庞大数据中心。理解它们之间的舞蹈——并发的优雅杂耍和并行的原始力量,以及它们的复杂性和陷阱——就是理解现代计算的基本节奏。

应用与跨学科联系

在遍历了并发和并行的原理之后,我们可能感觉像是在审视一台宏伟机器的抽象蓝图。现在,是时候走进引擎室,看看这台机器是如何运作的了。这些概念,即管理多任务与执行多任务之间的区别,是如何塑造我们这个世界的?事实上,它们无处不在,指挥着一场无声的交响乐,为从你口袋里的智能手机到全球金融市场乃至科学发现的前沿提供动力。这不仅仅是学术上的记账;它就是现代性能的精髓所在。

无形的交响乐:驱动数字世界

想象一位国际象棋大师,他不是在下一盘棋,而是在同时下二十盘。她从一个棋盘走到另一个棋盘,走一步棋,然后移到下一个。她正在并发地处理二十盘棋,她的大脑在交错处理每一盘棋的逻辑。但在任何一个瞬间,她只在思考一步棋。这就是​​没有并行的并发​​。现在,想象她有一个同样技艺高超的双胞胎。她们可以一起应对这二十盘棋,每个人在同一时间思考一步棋。这就是​​并行​​。这个简单的类比正是互联网运作方式的核心。

一个现代的网络服务器就像那位象棋大师,但它的对手是成千上万试图访问一个网站的用户。由此产生了两种基本策略。一种是事件驱动模型,类似于我们那位单人大师。这种服务器使用单一执行线程来处理所有连接。当它向数据库发送请求并需要等待时,它不会停下来。它会立即转向另一个需要处理的连接,就像我们的大师移到下一个棋盘一样。这是通过非阻塞I/O实现的,当数据准备好时,操作系统会通知服务器。这种方法在单个处理核心上效率极高,因为它不浪费任何空闲时间,并最大限度地减少了任务切换的开销。

另一种策略是多线程模型,就像拥有一支由象棋选手组成的军队。服务器为每个连接创建一个单独的线程(一个“选手”)。当一个线程需要等待I/O时,操作系统可以切换到另一个线程。在单核上,这种持续的切换——称为上下文切换——会产生一笔虽小但不可忽略的开销,就像一位经理在协调选手们。这可能使得在单核上,多线程服务器比其精简的事件驱动对手稍慢一些。

但是,当我们从一个CPU核心增加到八个时会发生什么?事件驱动服务器,作为一个单人大师,不能同时在多个棋盘上下棋;它的速度不会变快。然而,多线程服务器现在可以将其选手分配到不同的核心上,实现真正的并行。其吞吐量可以急剧扩展。这揭示了一个深刻的真理:并发是一种保持处理器忙碌的设计模式,而并行是硬件同时执行的能力。只有为利用并行而设计的架构才能真正利用多核世界。

这种平衡不仅仅关乎CPU。一个高性能服务器是一个系统,其瓶颈——最慢的部分——决定了整体速度。我们可以根据服务器网卡的带宽(RNICR_{\mathrm{NIC}}RNIC​)和其总CPU处理能力(RCPUR_{\mathrm{CPU}}RCPU​)来计算服务器能处理的最大请求率。如果CPU的速度是网络的两倍,系统将受限于网卡。无论软件多么巧妙,也无法推动比网络物理承载能力更多的比特通过网络。像[epoll](/sciencepedia/feynman/keyword/epoll)或IOCP这样的高级操作系统机制,是让服务器能够用少数线程管理巨大并发量——成千上万个连接——的工具,确保硬件(无论是CPU还是网卡)成为真正的限制,而不是软件处理任务的能力。

用户体验:瞬间响应的幻觉

这不仅关乎数据中心的大型服务器;它关乎你手机上应用的体验感。现代用户界面(UI)设计中唯一最重要的规则是:永远不要阻塞UI线程。这个线程负责绘制你所看到的界面并响应你的触摸。如果它被迫等待一个缓慢的网络下载,整个应用程序就会冻结。我们都见过:那个死亡旋转的菊花图标。

为了防止这种情况,应用程序开发者必须是并发大师。在不冻结应用的情况下从多个网络源获取数据的问题,有两种优雅的解决方案,都植根于我们的核心原则。第一种是异步模型,就像我们的事件驱动服务器一样。UI线程发起一个网络请求——一个非阻塞调用——然后立即返回其保持界面流畅的主要工作。它实质上是告诉操作系统:“去帮我取这个数据,完成后通知我。”当数据到达时,操作系统会在UI线程的事件队列中放置一个通知,该通知将在适当的时候被处理。

第二种模式是分流工作。UI线程将缓慢的、阻塞的任务交给一个后台线程上的“工作者”。这个工作者去等待网络,而UI线程则保持完全自由和响应。通过这样一个工作线程池,多个下载可以并发地(或在多核手机上并行地)进行,从而大大加快了加载屏幕的总时间。两种模式都达到了相同的目标:它们将长时间运行的、I/O密集型任务与对时间敏感的UI工作分离开来,创造出我们习以为常的无缝体验。

流水线:管道、瓶颈与流量控制

许多复杂的流程,无论是在计算领域还是其他领域,都可以被建模为一条装配线或一个管道。一个任务从一个阶段移动到下一个阶段,每个阶段执行一个特定的操作。一个经典的例子是​​生产者-消费者​​(Producer-Consumer)问题。一个线程(生产者)创建物品并放入共享缓冲区。另一个线程(消费者)从缓冲区中取出物品并进行处理。

缓冲区是关键。它将两个线程解耦。如果生产者更快,它可以填满缓冲区然后休息。如果消费者更快,它可以清空缓冲区然后等待。在有两个处理器核心的情况下,生产者和消费者可以真正并行工作。这个管道的整体速度不是它们速度的总和,而是两者中较慢那个的速度。无论缓冲区有多大,它都无法让最慢的阶段变快;它只能平滑波动,减少频繁启停的开销。

这个确切的原则也支配着现实世界的系统。考虑一个基因测序管道,一个样本必须经过准备(阶段A)、测序(阶段B)和比对(阶段C)。如果阶段B有两台并行的测序仪,但阶段C只有一个工作人员,我们可以精确地描绘出样本的流动。通过跟踪每个样本在每个阶段的完成时间,我们可以确定处理一批样本的总时间,即​​完工时间​​(makespan)。我们可能会发现,即使样本很快完成阶段B,它们也会排队等待阶段C的单个工作人员,从而使阶段C成为瓶颈。

在由​​微服务​​(microservices)构建的现代云应用中,这一点变得更加关键。想象一个管道,一个请求由服务 S0S_0S0​ 处理,它调用 S1S_1S1​, S1S_1S1​ 再调用 S2S_2S2​。每个服务都是并行副本的集合。总吞吐量受限于聚合能力最低的服务。如果流量高峰使瓶颈服务(比如 S1S_1S1​)过载,请求就会堆积。一个设计良好的系统会使用​​背压​​(backpressure):过载的服务 S1S_1S1​ 向上游的 S0S_0S0​ 发出信号,让其减速,从而防止级联故障。真正处理增加负载的唯一方法是通过增加更多副本来提高瓶颈阶段的并行度。如果你没有足够的并行处理能力来处理它们,仅仅增加你允许进入系统的并发请求数量是没有帮助的。

当事情必须逐一发生时:临界区与死锁

虽然我们通常希望尽可能多地并行做事,但有些操作是神圣的。它们必须一次一个地发生。在金融交易所中,一支股票的中央订单簿是一个共享资源。为保持一致性,一次只能处理一笔交易。这部分代码被称为​​临界区​​(critical section),并由锁或互斥锁保护。

这带来了一个深远的影响,即​​阿姆达尔定律​​(Amdahl's Law)所描述的。该定律指出,通过增加更多并行处理器所能获得的最大加速比,受限于工作中本质上是串行部分所占的比例。如果你程序的10%必须在临界区内运行,那么即使拥有无限数量的处理器,你也永远无法获得超过10倍的加速。对于一个证券交易所来说,如果撮合一笔订单是一个微小但必不可少的串行步骤,那么增加更多的核心可能对提高该单一股票的吞吐量几乎没有帮助。解决方案是什么?改变问题。交易所可以为不同的股票创建独立的订单簿(一种称为分片的技术),而不是使用一个大的订单簿。现在,Apple和Google的交易可以在不同的核心上并行处理,从而恢复了可伸缩性。

这种对资源的独占访问需求可能导致一种称为​​死锁​​(deadlock)的危险状态。让我们暂时离开计算机世界,考虑一个为工业负载管理电力的智能电网。假设有两家工厂A和B,每家都需要两台发电机来启动一个流程。电网有三台发电机可用。工厂A请求并获得了1号发电机。同时,工厂B请求并获得了2号发电机。现在,工厂A在等待第二台发电机,但剩下唯一的一台被B持有。而工厂B也在等待第二台发电机,但剩下唯一的一台被A持有。它们陷入了“死亡拥抱”,都无法继续进行。这是对死锁的完美诠释,死锁需要四个条件:互斥、持有并等待、不可抢占和循环等待。一个简单的预防方法是打破“持有并等待”条件:要求工厂必须一次性获得其所需的所有发电机,否则一台也得不到,必须等待。操作系统中使用同样的逻辑来防止进程因文件或打印机等资源而发生死锁。

挑战极限:算法并行性

最后,并行的潜力不仅仅是硬件的一个特性,更是深深植根于我们算法结构中的东西。在科学计算中,我们可以非常清晰地看到这一点。考虑一个图形处理单元(GPU),它是一个拥有数千个小核心的设备,专为大规模数据并行而设计。一个程序可以通过让主CPU在GPU忙于处理上一批数据时准备新数据来展现并发性。但真正的力量来自于GPU内部的并行性,成千上万的线程在不同的数据点上同时执行相同的指令——例如,计算屏幕上每个像素的新颜色。

然而,并非所有问题都如此易于处理。有些计算是“易并行”的,比如对图像应用滤镜。而另一些则是天生串行的。考虑使用像不完全Cholesky分解这样的方法来求解一个大型方程组。每个值的计算都依赖于前一个值,形成一个长的依赖链。该算法的并发度约为1;无论你投入多少处理器,它都无法并行化。相比之下,另一种方法如Jacobi预处理器对每个分量执行简单、独立的计算,使其成为完美的数据并行,其并发度等于问题的规模。因此,算法的选择,就是对其内在并行性本质的选择。

从我们应用的响应速度到云的架构,从金融市场的完整性到科学算法的根本结构,并发和并行的原理是我们计算织物的经纬线。理解它们,就是理解我们如何编排复杂性,如何平衡相互竞争的需求,以及如何构建不仅强大,而且优雅、响应迅速和稳健的系统。