try ai
科普
编辑
分享
反馈
  • 进程与线程

进程与线程

SciencePedia玻尔百科
核心要点
  • 进程是一个独立的资源所有权单元,拥有私有的内存地址空间,提供强大的保护和稳定性。
  • 线程是一个轻量级的执行单元,共享其进程的内存和资源,从而实现高效的通信和并发。
  • 由于存在 TLB 刷新等内存管理开销,进程间切换的性能成本显著高于线程间切换。
  • 选择使用进程还是线程是一项基本的设计决策,它影响着应用程序在现代硬件上的响应性、安全性和可扩展性。

引言

在数字世界里,我们的计算机不断上演着一场魔术:同时运行着网页浏览器、音乐播放器、代码编辑器以及数十个后台服务。这种并发执行的幻象是现代计算的基础,但操作系统是如何管理这个复杂的任务交响乐团,而又不让它们相互干扰的呢?答案就在于计算机科学中两个最基本的概念:​​进程​​和​​线程​​。理解它们之间的区别不仅仅是学术上的探讨,更是构建健壮、高效和安全软件的关键。本文将揭开这些核心组件的神秘面纱,展示驱动我们数字生活的精妙设计选择。

我们将踏上一段分为两部分的旅程。第一章​​“原理与机制”​​将剖析核心定义,探讨作为拥有独立私有内存的隔离堡垒的进程,以及在这些壁垒内运作的敏捷行动者——线程。我们将量化性能权衡,分析上下文切换的成本,并了解现代操作系统如何将这些实体视为一个灵活的共享与隔离谱系上的点,而非僵化的二元对立。随后,​​“应用与跨学科联系”​​一章将展示这一个设计选择——共享还是隔离——如何在整个计算领域产生深远影响。我们将看到它对应用程序响应性、调度器公平性、系统安全以及世界上最强大的超级计算机架构的影响。让我们从揭开操作系统最伟大幻象的帷幕开始。

原理与机制

想象你正在参加一场盛大的宴会。在你的餐桌上,你同时应付着几场对话:与左边的人探讨宇宙的奥秘,与右边的人讨论烘焙面包的艺术,还与对面的人谈论本地足球队本赛季的胜算。你处理着所有这些对话,无缝地切换着你的注意力。你的计算机也在做着类似的事情,但其规模几乎超乎想象。它同时运行着你的网页浏览器、音乐播放器、电子邮件客户端以及数十个后台服务。每个程序都在自己的小世界里运行,对其他程序浑然不觉。操作系统(OS)是如何实现这种宏伟的平行宇宙幻象的呢?理解它们不仅仅是一项学术活动,它就像学习魔术师最伟大戏法背后的秘密。它揭示了一个充满优雅设计、巧妙权衡以及软硬件之间美妙而复杂协作的世界。

私有计算机的幻象:进程

这场表演的主角,这些隔离世界的构建者,是​​进程​​。你可以把进程想象成操作系统为程序创建的一个自成一体的宇宙。当你双击一个应用程序图标时,操作系统不只是运行代码,它首先为代码建造了一座房子。这座房子就是进程。

这座房子由什么定义?首先,它有自己私有的​​地址空间​​。这是一个完整、独立的内存映射,从地址零到计算机能处理的最大地址。从进程内部看,它似乎独占了计算机的全部内存。你的网页浏览器的内存映射与你的文本编辑器的完全分离。这是一个极其强大的幻象。浏览器不能意外地(或恶意地)窥探编辑器的内存,看看你正在写什么,音乐播放器的漏洞也不会破坏浏览器的数据。

这种严格的分离是稳定、现代操作系统的基石。它提供了​​保护​​和​​隔离​​。我们甚至可以用​​故障爆炸半径​​这个概念来量化这一思想:如果一个程序崩溃,损害的范围有多大?因为每个进程都是其自己封闭的宇宙,一个进程中的故障被限制在其壁垒之内。“爆炸半径”仅限于那单个进程,操作系统可以清理并终止它,而不影响任何其他进程。

为了理解这为何如此重要,想象一个假设的操作系统,它取消了进程,只为所有东西提供一个全局地址空间。在这个世界里,一个行为不端的程序——一个缓冲区溢出、一个野指针——就可能覆盖任何其他程序甚至操作系统本身的内存。那将是一片混乱。这个思想实验表明,进程不仅仅是一个容器,它是一个堡垒,一个使得健壮、多用户和多任务系统成为可能的保护域。

除了地址空间,进程也是​​资源所有权​​的基本单元。操作系统将资源——打开的文件、网络连接、访问凭证——分配给进程,而不是原始代码。进程持有、管理并最终对它们负责。正如我们将看到的,当出现问题时,这一点变得至关重要。

壁垒内的生命:线程

所以,进程是一座房子。但是谁住在房子里并从事工作呢?这就是​​线程​​的任务。线程是一个可调度的执行上下文——即CPU实际执行的指令序列。你可以把它想象成一个演员,一个进程这座房子里的居民。

每个进程至少有一个线程。但真正的威力来自于一个进程拥有多个线程。例如,一个现代网页浏览器可能有一个线程处理用户输入(如滚动),另一个线程渲染页面,还有几个线程从网络下载图片和数据。所有这些线程都存在于同一个进程内部。

这意味着它们共享同一个地址空间。它们都是同一座房子的居民。它们可以看到相同的数据,调用相同的函数,并访问相同的资源。这是它们最大的优势。线程之间的通信极其快速和简单:如果一个线程想与另一个线程共享信息,它只需将其写入共享内存中的一个位置,另一个线程就可以立即读取。这就像给你的室友在厨房桌上留一张便条一样简单。

然而,每个线程都需要一些私人物品来理清自己的工作。它有自己的​​程序计数器​​(PCPCPC),用于跟踪当前正在执行哪条指令。它有自己的一套CPU​​寄存器​​,这就像它的短期草稿纸。它还有自己的​​栈​​,用于跟踪函数调用和局部变量。这些是线程的私有思想,是其独立的逻辑链。但房子本身——广阔的内存空间——是一个共享的公共区域。

隐私的代价:切换世界的成本

如果进程提供了如此美妙的隔离,为什么不把所有东西都用进程来做呢?为什么还要费心使用线程?答案,正如工程领域中常见的那样,是性能。进程提供的隔离是有代价的。

CPU是有限的资源。单个CPU核心一次只能执行一条指令。为了制造同时运行数百个线程和进程的幻象,操作系统调度器会进行一种名为​​上下文切换​​的闪电般快速的“偷天换日”。它让一个线程运行一小部分时间(一个时间片),然后快速保存其状态,加载另一个线程的状态,再让那个线程运行。

现在,考虑一下这种切换的成本。

  • ​​在线程之间切换(在同一进程内):​​ 这是廉价的。操作系统需要保存传出线程的寄存器和栈指针,并加载传入线程的相应部分。地址空间——那座房子——保持不变。这就像一个演员离开舞台,另一个演员进入;舞台布景没有改变。成本基本上就是保存和恢复寄存器的时间:tcsthread=tregst_{cs}^{thread} = t_{regs}tcsthread​=tregs​。
  • ​​在进程之间切换:​​ 这是昂贵的。除了保存和恢复寄存器,操作系统还必须彻底改变活动的地址空间。这意味着要告诉CPU的内存管理单元(MMU)使用一个不同的​​页表​​。页表是将进程的私有虚拟地址转换为实际物理RAM地址的映射。改变这个映射是一项繁重的操作。更糟糕的是,它会强制刷新​​转译后备缓冲器​​(TLB),这是一个存储近期地址翻译的关键硬件缓存。TLB刷新就像让CPU对内存布局产生“失忆症”;它必须慢慢地重新学习地址翻译,从而减慢执行速度。

这种开销就是“隐私的代价”。我们可以将进程切换的成本建模为 tcsproc=tregs+tpt+tTLBt_{cs}^{proc} = t_{regs} + t_{pt} + t_{TLB}tcsproc​=tregs​+tpt​+tTLB​,其中 tptt_{pt}tpt​ 是切换页表的成本,而 tTLBt_{TLB}tTLB​ 是TLB刷新的成本。那个额外的项,tpt+tTLBt_{pt} + t_{TLB}tpt​+tTLB​,正是进程内线程切换速度快上几个数量级的原因。这不仅仅是理论上的;工程师们使用精心设计的“乒乓基准测试”,将任务固定在单个CPU核心上以消除噪声,从而精确测量这些亚微秒级的延迟,并在真实世界中验证这些模型。

存在的谱系:进程-线程连续体

所以,我们有两种截然不同的模型:隔离的、重量级的进程和协作的、轻量级的线程。在很长一段时间里,故事到此为止。但现代操作系统已经意识到,这种二元选择可能过于僵化。

在像Linux这样的系统中,[fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 系统调用创建一个新进程(一座新房子),而 pthread_create() 创建一个新线程(当前房子里的一个新居民)。但Linux还提供了一把万能钥匙:clone() 系统调用。clone() 是一个通用的创建工具,它让程序员可以在细粒度上决定新实体将与其父实体共享什么。

  • 共享地址空间?可以。
  • 共享文件描述符表?可以。
  • 共享信号处理器?不行。
  • 共享一切?你刚刚创建了一个​​线程​​。
  • 什么都不共享?你刚刚创建了一个​​进程​​。

通过从可共享资源的菜单中挑选组合,你可以创建介于经典进程和经典线程之间的实体。这揭示了一个深刻的真理:“进程”和“线程”不是柏拉图式的理想模型。它们是描述隔离与共享这一丰富谱系上两个常见点的便利标签。操作系统提供了旋钮,系统设计者则根据手头的任务来调整它们,以在保护和性能之间取得完美的平衡。例如,选择共享地址空间,可以通过避免页表的重复和TLB刷新,显著降低内存开销和上下文切换成本。

谁拥有什么?资源、死锁和清理

让我们回到进程最关键但又最微妙的角色之一:它是​​资源所有权的单元​​。这对系统稳定性有着深远的影响,尤其是在出现问题时。

考虑死锁,那个并发编程中臭名昭著的状态,即两个或多个任务陷入循环等待,每个都持有着对方需要的资源。想象一个场景,其中一个线程 T1T_1T1​ 持有一个软件锁(一个互斥锁)并等待一个文件锁。该文件锁由另一个进程 QQQ 持有。而为了完成这个循环,进程 QQQ 正在等待另一个线程 T2T_2T2​ 持有的信号量,而 T2T_2T2​ 与 T1T_1T1​ 在同一个进程中。

系统现在冻结了。操作系统死锁检测器识别出这个循环,并且必须选择一个牺牲品来终止以打破循环。它应该怎么做?

  • ​​选项1:只杀死线程 T1T_1T1​。​​ 这看起来很精准。但会发生什么?T1T_1T1​ 持有的用户空间互斥锁只是内存中的一串比特位,内核对此一无所知。杀死 T1T_1T1​ 会使这个锁处于永久锁定状态,很可能会损坏应用程序的数据。更糟糕的是,进程 QQQ 等待的信号量由线程 T2T_2T2​ 持有。终止 T1T_1T1​ 对 T2T_2T2​ 没有任何影响,所以 T2T_2T2​ 继续持有该信号量。死锁并未解决。
  • ​​选项2:杀死整个进程。​​ 这看起来很激烈,但它干净利落且有效。当一个进程被终止时,操作系统有一个简单而铁定的规则:回收该进程拥有的所有资源。它的整个地址空间消失。它所有打开的文件被关闭。它所有的文件锁被释放。它所有的信号量被释放。一旦信号量被回收,进程 QQQ 就被解除阻塞,循环等待被打破,系统可以继续运行。

这个例子出色地说明了两者之间的区别。线程使用资源,但进程拥有它们。进程是操作系统与之签订合同的实体。它是从CPU时间到内存所有一切的记账单位,也是清理和恢复的锚点,确保即使个别程序灾难性地失败,系统也能保持稳定。

从内核的视角:操作系统真正看到了什么

这个故事还有最后一层。我们一直在讨论操作系统做什么,但操作系统实际上看到了什么?答案取决于​​线程模型​​。

在现代的​​一对一(1:1)模型​​中,这是Windows、Linux和macOS默认使用的模型,你在程序中创建的每个线程都对应一个内核知道并能独立调度的真实、独立的线程。如果你的应用程序有32个线程,操作系统就会看到32个可调度实体,并且如果可用,可以在32个不同的CPU核心上并行运行它们。

然而,一些系统或语言运行时使用​​多对一(M:1)模型​​。在这种模型中,应用程序运行时会创建许多用户级线程(ULTs),但将它们全部复用到操作系统看到的单个内核级线程(KLT)上。用户空间运行时在玩它自己的小型调度游戏,而内核对此一无所知。

这可能导致一些非常具有误导性的情况。想象一个使用这种M:1模型运行的程序。它有32个计算密集型的ULTs,都准备好运行。一位试图分析此应用程序的开发人员查看标准的操作系统工具。ps 命令报告该进程只有一个线程。系统的“负载平均值”——一个衡量有多少任务可运行的指标——徘徊在1左右。CPU监视器显示单个核心的使用率为100%。开发人员可能会得出结论:“嗯,这是一个单线程程序,已经把它的核心用满了。没有更多的并行性可以挖掘了。”

他们将完全错误。该应用程序的“逻辑负载”为32;它迫切需要32个核心的处理能力!但因为操作系统只能看到那一个KLT,它只给该进程一个核心的时间,并报告负载为1。巨大的内并发性被隐藏在抽象之后。这就是为什么现代性能分析需要与语言运行时进行深度集成,以暴露这种内部状态,从而提供真实的性能图景。

从同时运行两个程序的简单幻象,到线程模型和资源所有权的微妙复杂性,进程和线程的故事就是操作系统的微观缩影。它是一堂关于抽象的大师课,一场在性能与保护之间的持续平衡,也是对构建我们每天栖居的复杂、可靠且看似神奇的数字世界所需智慧的美丽见证。

应用与跨学科联系

现在我们已经拆解了进程和线程的内部机制,理解了它们的齿轮和弹簧,是时候看看我们能用它们建造出怎样美丽而复杂的机器了。我们已经看到,本质的区别很简单:进程在它们各自的私有内存宇宙中被隔离开来,而线程则在单个进程内共同栖居,共享一切。这个看似微小的区别不仅仅是一个技术细节。它是一个基本的设计选择,其影响回响在现代计算的每一层,塑造着从智能手机屏幕的流畅响应到世界最快超级计算机的架构等一切事物。

让我们踏上一段旅程,看看这一个理念——共享还是不共享——如何在广阔的计算机科学领域中发挥作用。

调度的艺术:公平、响应与幻象

想象你正在使用一个文字处理器。你打字,字母立即出现。在后台,应用程序正在自动检查你的拼写并保存你的文档。你感觉这些动作是同时发生的,但你的计算机可能只有一个或几个CPU核心。这个幻象是如何打造的?答案在于线程和一个聪明的调度器。该应用程序是单个进程,但它使用了多个线程:一个用于用户界面(捕获你的按键),一个用于拼写检查器,另一个用于自动保存。

操作系统的调度器可以被设计为偏爱某些类型的线程。例如,多级反馈队列(MLFQ)是一种杰出的调度器设计,它能学习线程的行为。那些频繁让出CPU的线程——比如等待你输入的UI线程——被识别为“交互式”并给予高优先级。只要它们有工作要做,它们就能立即运行,确保应用程序感觉响应迅速。而那些长时间、不间断运行的线程则被归类为“CPU密集型”,并给予较低优先级。这确保了后台计算不会让整个应用程序冻结。线程为构建具有这些多样化需求的应用程序提供了完美的模型,而调度器则扮演指挥家的角色,确保每个部分在正确的时间发挥其作用。

但这提出了一个深刻的问题:调度器的“公平”意味着什么?它应该对进程公平还是对线程公平?考虑一个为不同用户运行多个应用程序(进程)的服务器。如果调度器旨在给每个线程平等的CPU时间片,那么一个用户可以启动一个生成一千个线程的进程,从而不公平地垄断机器的资源。在这种情况下,拥有8个线程的进程获得的CPU时间是另外两个仅有2个线程的进程的四倍,即使所有进程被赋予了相同的重要性。这迫使我们进行更深入的思考。现代调度器通常使用“组调度”,它们首先在进程(或用户组)之间分配CPU时间,然后再将该分配细分给每个进程内的线程。进程和线程之间的简单区别迫使我们进行一场关于资源分配中公平性定义的复杂对话。

线程与性能之间的关系也可能具有欺骗性。许多编程语言,如Python和Ruby,使用一种称为全局解释器锁(GIL)的机制。虽然这些系统允许你创建多个原生线程,操作系统可以在不同的CPU核心上调度它们,但GIL是一个主锁,确保在任何给定时间只有一个线程能实际执行该语言的代码。如果你在一个双核机器上运行两个CPU密集型的线程,你不会看到加速。这些线程将并发运行,轮流持有GIL,但不是并行运行。它们的执行是交错的,而不是同时的。这是一个深刻的教训:线程是管理并发任务的工具,但它们并不是并行性能的神奇保证。要在这类系统中获得真正的并行性,你通常必须使用独立的进程,每个进程都有自己的内存和自己的解释器锁,从而摆脱GIL的单行道限制。

从协作到隔离:通信与安全

线程的最大优势在于其共享的地址空间;它们可以在相同的数据上无缝协作。而生活在隔离世界中的进程,则必须通过由操作系统仲裁的更正式的渠道进行通信,例如管道。管道是一个简单的导管:一个进程写入的内容,另一个进程可以读取。当多个写入者通过同一个管道发送消息时,我们如何防止消息被搅乱?内核提供了一个美妙的保证:任何小于特定大小(\text{PIPE_BUF})的写入都是原子的。它将作为一个单一、连续的块出现在管道中,绝不会与来自另一个写入者的数据交错。无论写入者是独立的进程还是同一进程内的线程,这个保证都成立 [@problem-id:3669802]。内核作为最终的仲裁者,提供了一个干净可靠的通信原语,抽象掉了用户级的并发模型选择。

然而,线程的共享特性也伴随着其自身的危险。因为一个进程内的线程共享诸如文件描述符表之类的资源,一个线程中的错误可能会对整个群体产生意想不到的后果。想象一个生产者进程有几个线程向一个管道写入数据,供一个消费者进程读取。为了表示完成,生产者必须关闭其管道的写入端。消费者随后会看到一个“文件结束符”(EOF),并知道数据流已经完成。但如果其中一个生产者线程有bug,忘记关闭它对管道的文件描述符会怎样?即使所有其他线程(以及主进程)都关闭了它们的描述符,这一个“泄漏的”描述符在内核看来仍然使管道的写入端正式开放。消费者将耗尽所有数据,然后永远阻塞,等待更多数据,永远不会收到它所期望的EOF。这是一个典型的例子,说明了线程的“共同命运”:一个线程的错误可能会使整个系统死锁。

这种共同命运的概念从简单的正确性延伸到系统安全的核心。考虑一个Web服务器进程,它同时处理来自许多不同客户端的请求,每个客户端会话由一个单独的线程管理。在基于角色的访问控制(RBAC)系统中,客户端根据其角色拥有某些权限。当管理员需要撤销特定客户端的角色时会发生什么?如果系统的安全模型是粗粒度的,只在进程级别分配角色,这是不可能的。你不能为整个进程撤销权限,因为那会不公平地影响所有其他客户端。你必须有一个足够精细的安全模型,将每个线程视为一个独特的行动者,承载它正在处理的特定会话的安全上下文。这样,撤销操作就可以精确地应用于受影响的那一个线程,而不会造成附带损害。因此,进程和线程之间的区别不仅关乎性能,也是构建安全、多租户系统的前提。

性能的架构:挑战极限

在高性能计算(HPC)领域,科学家们模拟从星系碰撞到蛋白质折叠的一切事物,进程和线程之间的选择成为一种大师级的战略决策,与硬件的物理架构深度交织。

即使在单个多核芯片上,也会出现奇怪的效应。一个进程中的线程共享同一个虚拟地址空间,该空间由硬件的内存管理单元(MMU)和一个称为转译后备缓冲器(TLB)的缓存来管理。当一个线程修改进程的页表时(例如,分配新内存),其他核心上缓存的地址翻译可能会过时。操作系统随后必须向那些其他核心发送处理器间中断(IPI),或称为“核间击落”(shootdown),强制它们刷新其TLB。如果一个进程的线程分布在机器的所有核心上,单个内存操作就可能引发一场破坏性的中断风暴。一个更聪明的调度器可能会使用核心亲和性,将一个进程的所有线程限制在一个小的、专用的核心子集上。这样,只有那少数几个核心需要参与TLB一致性协议,从而显著减少系统范围的开销。共享地址空间,这个便于通信的福音,却产生了一个必须被管理的隐藏的物理依赖。

这种软件模型与硬件现实之间的博弈,在由成百上千个互联节点构成的大型超级计算机上变得更加明显。这些系统通常使用一种混合编程模型:使用消息传递接口(MPI)在不同节点上启动进程,并使用OpenMP在每个节点内使用线程。

想象一个强大的节点,它有两个独立的CPU插槽,每个插槽都有自己的核心和直接连接的内存。这是一种非统一内存访问(NUMA)架构。访问同一插槽上的内存速度快;访问另一个插槽上的内存则明显较慢。如果你运行一个其线程分布在两个插槽上的单个进程,线程将不断地从“远程”内存中获取数据,从而造成性能瓶颈。最佳策略是将你的软件层次结构映射到硬件层次结构:每个插槽运行一个进程,将其固定在那里,并只在该插槽内的核心上使用线程。在划分科学问题时,你必须以一种最小化插槽间数据交换的方式来进行。

将此扩展到整个集群,选择取决于连接节点的网络。一些科学算法,如分子动力学中使用的粒子网格埃瓦尔德方法,需要全对全的通信模式,即每个进程都必须与所有其他进程通信。胖树拓扑结构的网络就是为此设计的,并且能很好地处理这种情况。在这样的机器上,使用大量进程(纯MPI)可能是有效的。然而,环面网络是为最近邻通信优化的,在全对全交换期间会遭受严重的争用。在环面网络上,正确的策略是使用具有较少、较大进程的混合模型。通过限制通信实体的数量(即进程),你可以避免网络瘫痪,即使这意味着每个进程内部由其线程完成更多的工作。进程和线程的完美平衡不是一个普适常数;它是算法和其运行的物理机器的函数。

超越内核:抽象的层次

进程和线程的概念是如此强大,以至于它们以不同的形式在不同的抽象层次上反复出现。这一点在虚拟化中表现得最为明显。当你运行一个虚拟机(VM)时,你是在一个主机操作系统之上运行一个完整的客户机操作系统。

从客户机VM内部看,世界看起来很正常:它有自己的进程,它在自己的虚拟CPU(vCPU)上调度这些进程。但从主机的角度来看,这些vCPU是什么?在许多现代虚拟机监控器中,客户机的每个vCPU都被实现为主机操作系统中的一个简单线程。主机调度器像看待任何其他线程一样看待这些vCPU线程,并将它们调度到物理核心上。我们有了一个美妙的层次结构:客户机进程被调度到客户机vCPU上,而vCPU本身又作为主机线程被调度到物理核心上。要真正理解VM内部一个进程的性能,必须能够看透这些抽象层,并追溯从客户机进程到执行其工作的特定主机线程的路径。“进程”和“线程”不仅仅是固定的实体,而是在一场宏大、多层次戏剧中反复出现的角色。

从打造响应式界面到确保服务器安全,从避免性能幻象到构建宇宙模拟的架构,孤立进程与协作线程之间的简单选择带来了深远而广泛的后果。这样一个基本概念能为我们提供一个镜头,通过它我们可以理解、设计和优化整个计算系统谱系,这正是计算机科学之美的明证。