try ai
科普
编辑
分享
反馈
  • 理解阻塞式系统调用及其对并发系统的影响

理解阻塞式系统调用及其对并发系统的影响

SciencePedia玻尔百科
核心要点
  • 在多对一线程模型中,一个用户级线程发起的单个阻塞式系统调用会阻塞唯一的底层内核线程,从而可能冻结整个应用程序。
  • 诸如带有事件多路复用(例如 epoll)的非阻塞 I/O 和异步 I/O(例如 io_uring)等技术,允许程序在不暂停执行的情况下处理慢速操作。
  • 多对多 (M:N) 线程模型提供了一种折衷方案,即使某些内核线程阻塞,也能保持并行性,但也引入了其自身的复杂性。
  • 在持有锁(尤其是自旋锁)的同时进行阻塞,可能导致灾难性的死锁,如优先级反转,因此为阻塞操作使用适当类型的锁至关重要。

引言

现代软件建立在并发的承诺之上——即能够同时执行多个任务,从而创造出响应灵敏的用户界面和高吞吐量的服务器。我们通常通过线程来实现这一点,我们将线程设想为并行执行的独立工作单元。然而,我们在代码中管理的线程(用户级线程)与操作系统(OS)真正在 CPU 上调度的线程(内核级线程)之间存在着一个关键的脱节。这个鸿沟是一个微妙但极具破坏性问题的根源:一个等待慢速操作的线程可能会出人意料地使整个应用程序陷入停顿。本文旨在解决并发编程中的这一根本性挑战。在接下来的章节中,我们将首先探讨不同线程模型背后的“原理与机制”,并精确解释阻塞式系统调用如何打破并发的幻象。然后,我们将审视“应用与跨学科联系”,揭示这一个概念如何影响从桌面应用到互联网上最强大的服务器的一切,并讨论工程师为克服它而开发的巧妙解决方案。

原理与机制

在我们进入计算世界的旅程中,我们经常会遇到美丽的幻象——这些抽象让巨大的复杂性变得简单易控。其中最强大的幻象之一就是​​线程​​的概念:一个独立的执行单元,一条我们可以创建、运行和推理的单一执行路径。我们将程序想象成一个充满这些线程的繁忙车间,每个线程都在并行地辛勤完成其任务。但如果我告诉你,你所看到的这个车间可能只是一个幻象呢?在幕后,车间真正的主人——操作系统(OS)——可能只看到了你的一小部分工人?

这就是我们所说的​​用户级线程​​和​​内核级线程​​之间迷人的区别。理解这一差异不仅仅是一项学术练习;它是解开为何一些最优雅的并发程序会陷入灾难性停顿,以及计算机科学家如何设计出巧妙方法摆脱这一陷阱的关键。

伟大的幻象:线程与木偶师

想象一下操作系统的内核是一位木偶大师。内核的手数量有限,用它们来控制一组提线木偶。这些木偶就是​​内核线程​​,是操作系统唯一真正知道如何在 CPU 核心上调度和运行的实体。在最简单、最直接的模型中,即​​一对一线程模型​​,你在程序中创建的每个线程都会得到一个专属的提线木偶。如果你创建了八个线程,木偶师就会看到八个木偶,并对它们进行单独管理。这种方式清晰、健壮且易于理解。

但是,创建和管理一个内核线程并非没有成本。每一个内核线程都会消耗内核内存,并增加木偶师的调度负担。如果我们想为一个高性能服务器创建数千甚至数万个线程呢?一对一模型可能会变得笨重不堪。

于是​​多对一模型​​应运而生。在这种模型下,你程序中的一个巧妙的库会创建一个单一的大型提线木偶——即一个内核线程。然后,它将数十、甚至数百个微小的用户级线程附加到这一个提线木偶上。这个用户级库就像一个次级木偶师,在主木偶上快速切换哪个小木偶处于“活动”状态。对于主木偶师(操作系统)来说,整个繁忙的车间看起来就像一个单独的工人。这个幻象效率极高:创建和切换用户级线程可以比涉及内核快上几个数量级。但这个幻象有一个脆弱的阿喀琉斯之踵。

敲响内核之门:阻塞式系统调用

你的程序在其用户空间中愉快地运行,但它无法独立完成所有事情。要执行特权操作,如从文件读取、通过网络发送数据,甚至只是等待一段时间,它都必须请求内核的帮助。这个请求被称为​​系统调用​​。这是对内核之门的一次礼貌的敲击。

许多这类系统调用是​​阻塞式​​的。想象一下,请求内核从一个缓慢旋转的硬盘中读取数据。数据尚未就绪。在阻塞式调用中,内核会说:“好的,我会帮你取。你先去睡一会,好了我会叫醒你。” 发起调用的内核线程会进入沉睡状态,从可运行线程列表中移除,并且不消耗任何 CPU。

现在,让我们将两个概念联系起来。在我们的多对一模型中,当数百个用户级线程中的一个决定发起一个阻塞式系统调用时,比如说用 [fsync](/sciencepedia/feynman/keyword/fsync) 将日志消息写入磁盘,会发生什么? 用户线程敲响了内核的门。内核看到来自该进程唯一内核线程的请求,便说:“这需要一些时间。小睡一会儿吧。” 就这样,整个提线木偶都被置于睡眠状态。

结果是灾难性的。因为唯一的内核线程被阻塞,所有附加于其上的用户级线程也立即被冻结。本应巧妙切换到另一个就绪线程的用户级调度器无法运行,因为它本身也是那些被冻结的线程之一。整个应用程序陷入停顿。这种一个慢速操作导致其后所有操作都停滞的现象,是​​队头阻塞​​的典型例子。我们美丽而高效的幻象就此破碎。一个等待磁盘的线程成功地阻塞了其他几十个本可以进行有效计算的线程。

寻求非阻塞之道:策略与出路

这一根本性冲突——用户级线程的效率与阻塞式调用的危险——驱动了操作系统和运行时设计数十年的创新。目标很简单:我们如何能在等待外部世界的同时,不让自己的世界陷入停顿?

策略1:不等待的艺术

避免因等待而被卡住的最简单方法是……不去等待。我们可以使用一个​​非阻塞​​的 read 调用,而不是一个会让我们的线程休眠的阻塞式 read 调用。这就像是透过内核的门缝窥探,而不是敲门等待。“数据准备好了吗?没有?好的,我稍后再来!” 这个调用会立即返回一个特殊的错误码,比如 EAGAIN,告诉我们稍后重试。

这避免了阻塞内核线程,但又带来了新问题:我们应该何时重试?在一个紧凑循环中反复窥探被称为​​忙等待​​,这种方式效率极低,白白消耗 CPU 周期。优雅的解决方案是​​I/O 多路复用​​,使用像 [epoll](/sciencepedia/feynman/keyword/epoll) 这样的系统调用。这就像是递给内核一张我们感兴趣的所有门的清单(网络套接字、数据管道等),然后说:“只有当这些门中至少有一扇可以无需等待就打开时,再叫醒我。”

这使得多对一调度器可以变得异常智能。当其所有用户线程都在等待 I/O 时,它可以发起一个单一的、阻塞的 [epoll](/sciencepedia/feynman/keyword/epoll)_wait 调用,高效地将整个进程置于睡眠状态。一旦任何套接字上有数据到达,内核就会唤醒我们唯一的内核线程,然后用户级调度器就可以分派正确的用户线程来处理它。这个策略非常强大,但它有一个关键的局限性:它只对内核可以监控“就绪状态”的事物有效,比如网络套接字。对于普通磁盘文件,这个技巧通常不起作用,迫使我们寻找其他方法。

策略2:异步的承诺

一个更深层次的解决方案是改变我们请求的性质。我们不再请求数据并等待它,而是向内核提交一个任务然后走开。这就是​​异步 I/O (AIO)​​。与内核的对话方式完全改变了:“亲爱的内核,请为我执行这个 [fsync](/sciencepedia/feynman/keyword/fsync) 操作。当你完成后,请在我设置的这个特殊邮箱里留一个完成通知。我现在要回去做其他工作了。”

像 Linux 的 [io_uring](/sciencepedia/feynman/keyword/io_uring) 这样的现代接口是这种设计的巅峰之作。提交调用是非阻塞的;我们唯一的内核线程保持可运行状态,我们的用户级调度器可以自由地运行其他线程。内核完全在后台执行慢速的磁盘操作。稍后,用户级调度器可以检查它的“邮箱”(一个与内核共享的内存队列),看看是否有任何任务已完成。这种模型完美地将操作的发起与其完成解耦,彻底解决了多对一运行时的队头阻塞问题。

策略3:改变游戏规则

如果多对一模型是我们困境的根源,那么也许我们应该改变模型本身。

​​多对多 (M:N)​​ 模型是一个美妙的折衷方案。在这里,我们的用户级运行时为一个包含 UUU 个用户级线程(其中 U>KU > KU>K)的程序管理一个由 KKK 个内核线程组成的池(我们的提线木偶团队)。现在,如果一个用户线程发起了一个阻塞调用,它只会让 KKK 个内核线程中的一个进入睡眠状态。仍然在其余 K−1K-1K−1 个内核线程上活动的用户级调度器,可以简单地将其他就绪的用户线程移到那些活动的内核线程上。

但用户级调度器如何知道它的一个内核线程已经进入睡眠状态了呢?这需要内核的协作。实现这一目标最优雅但也是最复杂的机制被称为​​调度器激活 (Scheduler Activations)​​。当内核阻塞进程的一个内核线程时(例如,因为 I/O),它会通过一个新的内核线程向该进程发送一个“上调”(upcall)。这个上调是一个通知,意为:“我拿走了你的一个工人,但这里有一个替代品,这样你就可以保持你的并行水平。” 同样,当 I/O 完成时,另一个上调会通知运行时,原来的工人又可以用了。

理论上,这是两全其美的方案:兼具用户级调度的效率和内核线程的并行性。然而在实践中,这种错综复杂的协作舞步也有其自身的成本。在 I/O 率极高的系统中,持续的上调流会产生显著的开销,消耗本可用于应用程序本身的 CPU 时间。此外,内核提供替代工人的承诺只是一种尽力而为的保证;在一个高负载的系统上,它可能无法做到,导致暂时的并行性损失,即所谓的“资源供给不足”。

一个警告:锁与阻塞

线程、阻塞和第三个概念——锁——之间的相互作用,可能导致并发编程中最隐蔽的错误。想象一个简单的​​自旋锁​​,它通过忙等待工作:一个试图获取被持有锁的线程只是在一个紧凑循环中空转,反复检查锁直到它被释放。如果锁被持有的时间极短,这种方式是高效的。

现在考虑在单 CPU 系统上的这个噩梦场景:

  1. 一个低优先级线程, TlowT_{low}Tlow​, 获取了一个自旋锁。
  2. 在临界区内, TlowT_{low}Tlow​ 发起了一个阻塞式系统调用(这是一个大忌!)。它被内核置于睡眠状态。
  3. 一个高优先级线程, ThighT_{high}Thigh​, 醒来,想要同一个锁。它开始自旋,消耗了 100% 的 CPU。
  4. TlowT_{low}Tlow​ 的阻塞调用完成了!它现在已经准备好运行并释放锁。

但它将永远没有机会。严格的优先级调度器看到 ThighT_{high}Thigh​ 已经准备好运行,并把 CPU 交给它。ThighT_{high}Thigh​ 继续自旋,等待一个由 TlowT_{low}Tlow​ 持有的锁。但是 TlowT_{low}Tlow​ 无法运行以释放锁,因为它正被那个等待它的线程饿死 CPU 时间!这是一种被称为​​优先级反转​​的致命拥抱,会导致完全的死锁。

这个教训是严酷且绝对的:​​在持有自旋锁的情况下,永远不要进行阻塞操作。​​ 自旋锁用于保护微小的、原子的、内存中的操作。对于任何可能阻塞的操作,必须使用“休眠”互斥锁(sleeping mutex),这种锁在必须等待时会明智地通知内核调度器,从而允许其他线程运行,防止这些致命的死锁。并发这支错综复杂的舞蹈不仅需要理解每一种乐器,还需要理解它们如何在操作系统的宏大交响乐中协同演奏。

应用与跨学科联系

在计算的核心,有一个简单的、近乎哲学的问题:当一个程序请求某件不是立即可得的东西时,它应该做什么?如果它请求来自网络的数据,或来自磁盘的文件,它应该耐心等待,还是应该在此期间找点别的事情做?这个选择——阻塞还是不阻塞——看似一个微不足道的实现细节。实际上,这是一个基础性决策,其后果会波及我们软件的整个结构,决定着从我们手机上应用的响应速度到支撑互联网的服务器的惊人吞吐量的一切。

这就是关于这个选择的故事,以及对它的深刻理解如何让我们构建出复杂、可靠且高效的系统。这种张力源于我们的程序所栖居的两个世界:操作系统所见并调度的内核线程世界,以及我们在应用程序内部创建的、一个私有的任务宇宙——用户级线程世界。这两个世界之间的摩擦,正是魔力与麻烦开始的地方。

冻结界面的痛苦

一个位置不当的阻塞调用所带来的最直观、最普遍的后果,莫过于应用程序冻结。我们都见过:点击一个按钮,整个程序变得毫无响应,窗口变灰,对我们疯狂的点击置之不理。发生了什么?

罪魁祸首通常是将所有鸡蛋放在一个篮子里的设计。想象一个线程模型,其中许多用户级任务——一个用于用户界面(UI),一个用于后台计算,一个用于保存文件——都在单个内核线程之上进行管理。这就是“多对一”模型。从操作系统的角度看,你的整个应用程序只是一个可调度的实体。现在,假设保存文件的任务发起了一个阻塞式系统调用来写入慢速磁盘。操作系统会按指令行事:它将唯一的内核线程置于睡眠状态,直到磁盘操作完成。但由于 UI 任务也运行在那个睡眠的内核线程上,它也被暂停了。一个用户事件,比如鼠标点击,可能会到达,但没有“醒着”的线程来处理它。应用程序被冻结了,被一个单一的慢速操作所绑架。

相比之下,“一对一”模型,即将每个用户任务映射到其自己的内核线程,则能优雅地处理这种情况。保存文件的线程可以进入睡眠,但 UI 线程有自己的内核级上下文并保持可运行状态,随时准备被操作系统调度。应用程序保持流畅和响应。

这引出了所有现代事件驱动编程的根本法则,尤其是在 UI 中:​​你不可阻塞事件循环​​。事件循环是程序的中央神经系统,不断检查新事件——鼠标点击、网络数据包、定时器到期——并分派处理程序来处理它们。如果一个处理程序决定执行一个漫长的、阻塞的操作,整个循环就会陷入停顿。

更糟糕的是,在持有共享资源的同时进行阻塞是死锁的温床。想象一个 UI 线程锁住了一块数据,然后发起一个阻塞调用来等待一个工作线程的结果。但工作线程要产生那个结果,它首先需要获取 UI 线程正持有的同一个锁。UI 线程在等待工作线程,而工作线程在等待 UI 线程。两者都无法前进。应用程序不只是冻结了;它被永久地卡住了。唯一安全的路径是拥抱异步:发起耗时操作并提供一个“回调”让事件循环在其完成后执行,同时绝不在等待期间持有锁。

有些人可能会被一个看似聪明的技巧所诱惑:如果一个函数不真正阻塞,而是通过启动自己的“本地”事件循环来模拟等待呢?这是一条危险的道路。它会引发重入——即程序在处于未完成状态时重新进入相同代码的可能性。如果外部函数持有锁并且数据处于不一致状态,重入的代码可能会观察到这种损坏,或者更阴险地,试图再次获取同一个非递归锁,导致线程与自身死锁。

互联网的引擎:吞吐量与并发

让我们从单个用户的个人体验转向处理成千上万并发连接的互联网服务器的宏大尺度。在这里,阻塞的代价不仅仅是挫败感;它是吞吐量的灾难性损失。

考虑一个基于多对一模型构建的服务器。当一个需要少量 CPU 工作和少量 I/O(比如从数据库读取)的请求进来时,阻塞的 I/O 调用会迫使所有其他待处理的请求等待。处理一批请求的总时间变成了所有 CPU 工作时间的总和加上所有 I/O 等待时间的总和。工作被序列化成一个漫长而缓慢的队列。

现在,考虑一个一对一或“多对多”模型(其中许多用户任务被多路复用到一个较小但大于一的内核线程池上)。这种架构实现了真正的并发。当一个内核线程因等待数据库而被阻塞时,另一个内核线程可以使用 CPU 核心来执行另一个请求中需要 CPU 的部分。一个请求的 I/O 等待时间与另一个请求的计算时间重叠。处理这批请求的总时间现在大约是总 CPU 工作量除以核心数,再加上单个 I/O 等待的时间。性能的提升不仅仅是边际的;它可以是数量级的,这也是现代 Web 服务器如此高效的基本原理。从阻塞设计切换到异步设计所带来的性能增益,粗略地说,恰好等于之前因阻塞而浪费的时间。

现代编程语言已经通过 async/await 等特性拥抱了这一点,这些特性为编写非阻塞的异步代码提供了简洁的语法。运行时可以将这些 async 任务以多对多方式映射到一个内核线程池上。这提供了一个绝妙的折衷:用户级任务的低成本与内核线程的并行性。但这个模型有一个新的阿喀琉斯之踵:它依赖于协作。如果一个任务成为 CPU 占用大户,长时间进行计算而从不 await(这是将控制权交还给调度器的机制),它就会饿死分配给同一内核线程的所有其他任务。即使其他任务的 I/O 已经完成,调度器也没有机会运行,系统的响应性也会受到影响。

隐藏的陷阱与系统范围的涟漪

阻塞的危险性因其可能隐藏在最意想不到的地方而被放大。一个开发者可能会写一个调用标准库函数 getaddrinfo 的代码,来将域名解析为 IP 地址。这看起来像一个简单的函数调用。但在底层,它可能涉及通过网络发送 UDP 数据包并等待 DNS 服务器的响应。它是一个伪装的阻塞调用!对于一个基于多对一模型构建的运行时系统来说,这样的调用是毒药,能够让整个应用程序停滞。为了防范这种情况,复杂的运行时会采用一种工程上的柔道术:它们拦截这些可能阻塞的调用,并将它们委托给一个独立的辅助线程池。从主线程的角度来看,该调用会立即返回一个未来结果的承诺,巧妙地将一个同步、阻塞的世界转变为一个异步、非阻塞的世界。

阻塞的影响也可以创造出美妙的、系统范围的反馈循环。考虑一个反向代理服务器,它从客户端读取数据的速度比它将数据写入慢速磁盘的速度要快。代理使用缓冲 I/O 写入磁盘,这意味着数据首先堆积在操作系统的内存页缓存中。这些未写入的页是“脏页”。随着代理继续向系统涌入数据,脏页的数量会增长,直到达到内核定义的限制。此时,内核会介入并施加背压。它会强制代理的下一次 write 系统调用阻塞,直到一些脏页被安全地写入磁盘。

这个简单的阻塞引发了一场壮观的连锁反应。被阻塞的 write 调用使代理的事件循环停滞。停滞后,代理停止从客户端网络套接字读取数据。内核中的套接字接收缓冲区被填满。一旦填满,TCP 协议本身就会启动,向互联网另一端的原始客户端发送一个信号,告诉它停止发送数据。一个大陆上服务器的慢速磁盘,通过一连串的阻塞事件,优雅地对另一个大陆上的客户端进行了限流。这是一个美妙的、涌现的、自调节的机制,它将磁盘、内存和网络子系统连接成一个有机的整体。

深入底层:诊断与调试

作为工程师和科学家,我们如何观察这些线程和调度器的无形之舞?我们有工具可以让我们窥探底层。像 Linux 上的 strace 这样的工具允许我们实时观察一个进程所做的系统调用。通过观察这些模式,我们可以成为系统侦探。如果我们看到一个有四个逻辑线程的程序始终只使用一个内核线程 ID,并且当一个阻塞调用发生时所有活动都停止了,我们就可以推断它正在使用多对一模型。如果我们看到四个不同的内核线程 ID,并且它们继续并行工作,我们就发现了一个一对一模型。而如果我们看到,比如说,我们的四个任务使用了两个内核线程,并且系统只有在两者都阻塞时才停滞,我们就揭示了一个多对多模型。我们可以通过它在沙滩上留下的脚印来识别其架构。

当必须调试时,阻塞和并发的挑战最为深重。竞争条件——那些只在特定的、不幸的线程时序下才出现的错误——是出了名的难以复现。这是因为并发本质上是非确定性的。为了驯服这种混乱,我们可以使用“确定性重放”。其思想是记录一次执行过程中所有非确定性的来源,以便在下一次执行中可以“重放”得一模一样。在一个多对一系统中,这一点尤其引人入胜。操作系统对用户级调度器的选择一无所知。因此,要复现一个竞争条件,我们必须记录用户级调度器在每一个调度点所做的决定,以及任何影响它的外部信号(如定时器中断或调试器陷阱)的时机。只有通过捕获这种隐藏的、内部的非确定性,我们才能精确地重放执行过程,并将错误置于我们的显微镜下。

归根结底,这个看似简单的“是等待还是继续”的问题是一个深刻的问题。它迫使我们思考编排、资源管理,以及在简单性、效率和健壮性之间的复杂权衡。从滚动动画的平滑度到全球互联网的稳定性,对阻塞和非阻塞操作的巧妙管理是现代计算中一个至关重要但又常常无形的支柱。