try ai
科普
编辑
分享
反馈
  • 颠簸的成因:从操作系统原理到现代系统

颠簸的成因:从操作系统原理到现代系统

SciencePedia玻尔百科
核心要点
  • 颠簸是一种灾难性的系统状态,其中由于内存超订导致的过度页面交换,会引起 CPU 利用率崩溃。
  • 颠簸的明确特征是高缺页率和极低的 CPU 利用率同时出现。
  • 一个程序的“工作集”是其高效运行所需的最少内存页面集合,将其保留在 RAM 中是防止颠簸的关键。
  • 颠簸原理是一种普遍的资源争用模式,体现在 CPU、数据库、机器学习和云系统中。

引言

颠簸(Thrashing)是计算领域中最反直觉且最关键的故障之一:系统变得越来越忙,但实际上几乎一事无成,在自身的重压下陷入停顿。这种性能崩溃的特点是,尽管系统活动频繁,CPU 利用率却急剧下降,这是一个常见但常被误解的问题。它给开发人员和系统管理员带来了重大挑战,他们观察到系统急剧变慢,却没有一个明显的单一原因。本文通过探讨颠簸的根本原因和深远影响,来揭开其神秘面纱。

首先,在“原理与机制”部分,我们将剖析虚拟内存、缺页中断和工作集模型等核心概念,以理解操作系统管理内存的尝试如何导致毁灭性的反馈循环。我们将学习如何通过识别其独特特征来诊断颠簸,并将其与其他性能瓶颈区分开来。然后,在“应用与跨学科联系”部分,我们将拓宽视野,揭示同样的资源争用模式如何出现在不同领域。我们将从 CPU 缓存的微观世界,到云基础设施的宏大规模,揭示颠簸如何影响数据库系统、机器学习工作负载和虚拟化环境,证明它是系统设计中的一个普遍挑战。

原理与机制

要理解颠簸,就要领会计算领域中最富戏剧性的故事之一:一个关于野心、幻象和崩溃的故事。这个故事关乎软件对内存的无限渴望与物理硬件有限现实之间的根本矛盾。上演这出戏剧的舞台,就是​​虚拟内存​​的概念。

虚拟内存的钢丝绳

想象一位大厨在厨房里工作。操作台是他的工作区——快速、易于访问、触手可及。这就是你计算机的物理内存,即​​RAM(随机存取存储器)​​。它速度极快,但大小有限。现在,想象一个巨大的储藏室,绵延数里,存放着所有可能的食材。这就是你的硬盘或 SSD——空间宽敞但速度缓慢。

一个程序,就像我们的大厨,希望把它可能需要的每一种食材都摆在操作台上。但这是不可能的。于是,操作系统(OS)施展了一个华丽的魔术。它给每个程序一种错觉,让它们以为自己拥有一个巨大、私有的操作台。这就是​​虚拟内存​​。操作系统将程序的内存分解成固定大小的块,称为​​页面(pages)​​,而物理 RAM 则被划分为同样大小的块,称为​​帧(frames)​​。

操作系统扮演着一个勤奋的厨房助理的角色。当大厨(程序)需要一种食材(访问一个内存地址)时,助理会检查它是否已经在操作台上(在 RAM 帧中)。如果在,太好了!访问速度快如闪电。但如果不在,这个魔术就暂时失灵了。程序的执行被暂停,操作系统触发一次​​缺页中断(page fault)​​。这不是一个错误,而是一个信号,一个给操作系统的陷阱。这就像大厨在说:“我需要藏红花!”助理现在必须匆忙跑到巨大的储藏室(磁盘),找到藏红花(所需的页面),然后把它放在操作台的一个可用位置上(一个空闲的帧)。这次去储藏室的行程很慢——比从操作台上拿取已有的东西慢上数千甚至数百万倍。

工作集:理智的孤岛

幸运的是,程序和厨师一样,并非杂乱无章。一位制作炖菜的厨师会在一段时间内反复使用一小组食材——洋葱、胡萝卜、芹菜、汤锅。他们表现出​​引用局部性(locality of reference)​​。程序也是如此;它们倾向于在一段时间内使用一小部分、局部的指令和数据,然后再转向另一组。

一个程序当前需要的这组活跃的、局部的页面,就是它的​​工作集(working set)​​。这是程序的个人舒适区,是其活跃的内存页面“桌面”。只要一个程序的工作集能够完全装入分配给它的物理 RAM 中,这位大厨就能将所有当前食材都放在操作台上。缺页中断很少发生,仅在程序转换到新任务时(比如从做汤转向做甜点)才会出现。系统是高效且响应迅速的。虚拟内存系统的根本目标就是将被激活程序的工作集保留在 RAM 中。

悬崖边缘:从利用率到崩溃

为什么要同时运行多个程序?为了让最昂贵的组件——​​CPU(中央处理单元)​​——保持繁忙。如果一位厨师在等待烤箱预热(等待 I/O),另一位厨师就可以使用炉灶(执行指令)。随着我们增加​​多道程序度(degree of multiprogramming)​​——即活跃进程的数量——CPU 利用率最初会上升。因为几乎总有一个进程准备好运行,所以 CPU 的时间不会被浪费。

这在一定程度上效果很好。想象一下,往厨房里增加越来越多的厨师。操作台变得拥挤不堪。在某个关键时刻,所有厨师活跃食材所需的总空间——他们工作集的总和——超过了操作台的总空间。

这就是临界点。为了给厨师 A 拿一种新食材,助理现在必须移走另一位厨师(比如厨师 B)正在使用的食材。系统现在已经满负荷。它正站在悬崖边上,一小步就可能导致灾难性的坠落。

大崩溃:恶性循环

现在,我们再增加一个进程。它的工作集需要空间,但已经没有空间了。当它请求一个页面时,操作系统必须选择一个“牺牲品”帧来清空。它窃取了属于其他某个进程的帧。但由于所有的帧都承载着其他进程活跃工作集的一部分,操作系统被迫窃取了另一位厨师正在积极使用的食材。

这引发了毁灭性的多米诺骨牌效应——​​颠簸(thrashing)​​的恶性循环:

  1. 进程 A 发生缺页中断。操作系统必须为 A 调入一个页面。它窃取了一个持有进程 B 工作集页面的帧。
  2. 进程 B 被调度运行。但它接下来需要的页面恰好就是刚刚被窃走的那个!它立即发生缺页中断。
  3. 操作系统现在必须为 B 调入页面。它又窃取一个帧,可能来自进程 C,甚至来自进程 A。
  4. 很快,每个进程都处于一种永远需要刚刚被拿走的页面的状态。

这些进程几乎没有做任何有用的工作。它们几乎所有的时间都花在等待上。厨师们都静静地站着,双手叉腰,等待食材。厨房助理们疯狂地在储藏室和厨房之间来回奔跑,但没有任何烹饪工作在进行。CPU,这个主要的工作者,却处于空闲状态。曾一度攀升至 100%100\%100% 的 ​​CPU 利用率​​,骤降至接近零。这就是崩溃。这就是颠簸。

这种崩溃的必然性可以通过简单的算术看出。假设处理一次缺页中断——从高速 SSD 读取一个页面——仅需 888 毫秒(tpf=8 mst_{\text{pf}} = 8 \text{ ms}tpf​=8 ms)。如果系统超负荷到每秒总共产生 150 次缺页中断,那么 I/O 系统每秒需要花费在分页上的总时间是: I/O Demand=150faultss×0.008sfault=1.2\text{I/O Demand} = 150 \frac{\text{faults}}{\text{s}} \times 0.008 \frac{\text{s}}{\text{fault}} = 1.2I/O Demand=150sfaults​×0.008faults​=1.2 这个无量纲的数字是可怕的。它意味着每过 111 秒的真实时间,系统就需要 1.21.21.2 秒的 I/O 服务时间来仅仅处理缺页中断。这在物理上是不可能的。磁盘的等待队列将无限增长,系统因其自身的内存管理开销而完全饱和,最终陷入停顿。

诊断病症:是颠簸吗?

系统变慢是一种症状,但颠簸是一种特定的疾病。就像一位好医生一样,系统工程师必须寻找特定的生命体征组合来做出正确诊断,并排除其他病症。

颠簸的经典特征是三个观察指标的组合:高​​缺页率(ppp)​​、​​交换设备的长队列(qqq)​​,以及低​​CPU利用率(UUU)​​。

这个特征使我们能够将颠簸与两种常见的“冒名顶替者”区分开来:

  • ​​CPU 饱和​​:一个因 CPU 成为瓶颈而过载的系统。在这种情况下,CPU 利用率接近 100%100\%100%,准备运行的进程队列很长。而在颠簸中,CPU 是空闲的。
  • ​​非分页 I/O 瓶颈​​:系统变慢是因为它在等待磁盘,但原因不同——例如,一个大型数据库查询。在这种情况下,CPU 利用率也可能很低,但缺页率是正常的。I/O 队列很长,但针对的是存放应用数据的磁盘,而不一定是交换设备。

为了建立一个真正有因果关系的论证,人们可以从被动观察转向主动实验。如果你怀疑发生了颠簸,可以进行一次石蕊测试:暂时挂起一两个内存密集型进程。这会减少总内存需求。如果系统真的在颠簸,这种压力减轻将导致缺页率急剧下降,并且由于进程不再永远被阻塞,CPU 利用率将大幅飙升。如果移除一个进程能让系统变得更快,你就找到了颠簸的确凿无疑的特征。

颠簸的多种面貌

上述恶性循环是颠簸的经典形式,但这种病态现象以许多微妙和现代的方式出现。根本原因总是一样的——内存需求超过供给——但触发因素可能更为复杂。

  • ​​内存超售的欺骗性承诺​​:现代操作系统是乐观的。当你的程序通过像 malloc 这样的调用请求一吉字节(GB)的内存时,即使操作系统没有一吉字节的空闲 RAM,它也可能回答“是”。它在赌你不会一次性使用全部内存。这就是​​内存超售(memory overcommit)​​。分配成功了,但这只是一个承诺。当你的程序真正开始接触那些页面时,账单就来了。如果有足够多的进程同时让操作系统兑现承诺,系统就会陷入颠簸。

  • ​​病态工作负载​​:虚拟内存系统的魔力完全依赖于引用局部性。如果一个程序根本没有局部性呢?想象一个进程在一个远大于 RAM 的海量数据集上随机访问页面。再聪明的页面替换算法也无法预测接下来会发生什么。几乎每一次访问都是缓存未命中和缺页中断。这是最坏的情况,即程序自身的访问模式击败了系统并引发了颠簸。

  • ​​附带损害与缓存污染​​:颠簸并非总是自作自受。一个工作集小而稳定的健康进程可能会成为​​缓存污染(cache pollution)​​的受害者。想象一下,一个进程开始进行大规模、高速的顺序文件扫描(比如在巨大的日志文件上运行 grep)。它就像一根消防水管,每秒将数千个新的一次性使用的页面冲刷过系统的页面缓存。这种洪水般的数据流如此强烈,以至于它会冲掉其他重要应用程序宝贵的、频繁重用的“热”页面,导致它们发生颠簸。

  • ​​硬件层面的问题​​:问题一直延伸到芯片层面。当操作系统换出一个页面时,如果该页面已被修改(即它是“脏”页),那么在重用该帧之前必须将其写回磁盘。一个写入量大的工作负载会产生许多脏页,从而减慢了每一次换出操作。在现代 SSD 上,还存在另一种隐蔽的效应:​​写放大(write amplification)​​。由于闪存的物理特性,写入一个逻辑上的 444 KiB 页面可能会迫使驱动器的内部控制器擦除并重写一个大得多的块,实际上可能执行了例如 121212 KiB 的物理 I/O。这成倍增加了保存脏页的时间成本,极大地增加了 I/O 需求,使系统更容易发生颠簸。

归根结底,颠簸揭示了计算机系统中一个美丽而可怕的统一性。它表明,性能不仅仅是单个组件的特性,而是整个技术栈的涌现属性——从应用程序的访问模式,到操作系统的策略,一直到底层存储设备的物理行为。它严酷地提醒我们,即使是最聪明的幻象也有其破灭点。

应用与跨学科联系

我们已经探讨了颠簸的原理,将其视为当内存需求超过供给时的一种性能崩溃。你可能会倾向于认为这是一种罕见的病症,一种局限于操作系统设计深奥世界的疾病。事实远非如此。颠簸是计算领域中最基本、最普遍的挑战之一。它是一种资源争用的普遍模式,以无数种伪装形式出现,从处理器的核心到庞大、分布式的云端机器。

为了真正领会这一点,让我们踏上一段旅程。我们将化身为侦探,在不同的技术层面寻找颠簸的蛛丝马迹。我们将看到这个单一而优雅的原理如何解释了众多领域中的性能谜团,以及理解它为何是构建快速可靠系统的关键。

机器心脏中的颠簸:CPU 缓存

我们的第一站是最基础的层面:处理器本身。在 CPU 内部深处,是缓存(cache),一块小巧、速度极快的内存,充当处理器核心的个人便笺簿。它的工作是保存最近使用的数据,以避免前往主系统内存(RAM)的漫长旅程。但即使是这个微小的世界,也无法免受交通堵塞的影响。

想象一个程序陷入一个简单的紧密循环中。也许它在处理图像中的像素或矩阵中的值。假设这个循环反复访问少数几个内存位置,比如说 kkk 个。现在,由于内存地址映射到缓存位置的方式,这 kkk 个位置有可能全部被分配到缓存中的同一个小邻域——一个“缓存组(cache set)”。如果这个邻域只能容纳,比如说 aaa 个项目,而程序需要处理 kkk 个项目,其中 k>ak > ak>a,我们就得到了一个灾难的配方。

每次循环请求一个不存在的项目时,缓存都必须驱逐一个现有项目来腾出空间。因为循环会遍历所有 kkk 个项目,所以当它回到第一个项目时,该项目早已为其他项目腾出空间而被驱逐了。结果是一种病态:几乎每一次内存访问都会导致缓存未命中。处理器没有全速计算,而是把所有时间都花在等待数据在主存之间来回 shuffling 上。这是一个完美的、微观的颠簸例子。你可能已经猜到,解决方案是确保缓存的“邻域”足够容纳。一个组的容量,即其相联度(associativity) aaa,必须至少与映射到它的循环项目数 kkk 一样大。这个简单的规则,a≥ka \ge ka≥k,是高性能硬件设计的一项基本原则,直接源于避免颠簸的需求。

经典罪魁:操作系统的内存之舞

从硬件向上,我们来到了颠簸的传统家园:操作系统的虚拟内存子系统。在这里,资源不再是几千字节的缓存,而是数吉字节的物理 RAM,竞争者也不再是内存位置,而是整个程序。

颠簸出现的最戏剧性的方式之一是通过一种称为写时复制(Copy-on-Write, CoW)的优化。当一个进程创建一个子进程时(在像 Linux 这样的系统中使用 [fork()](/sciencepedia/feynman/keyword/fork()|lang=zh-CN|style=Feynman) 是一个常见操作),操作系统会施展一个聪明的技巧。它不是费力地为子进程复制所有父进程的内存,而是简单地让子进程共享父进程的页面,并将它们标记为只读。这是一个承诺:“你可以看,但别碰。如果你需要写入,我会为你制作一个私有副本。”这使得创建进程的速度变得难以置信地快。

但这个承诺是一颗定时炸弹。想象一下,子进程立即开始一个写密集型任务,修改其继承的大部分内存。每当第一次写入一个共享页面时,就会发生一次“CoW 缺页中断”。操作系统必须暂停该进程,分配一个新的物理内存页面,复制旧页面的内容,然后让进程继续。如果子进程在短时间内写入数千个页面,这将引发对新内存的突发性、大规模需求。如果可用的空闲内存不足,系统就会陷入颠簸。它疯狂地试图换出其他数据以满足 CoW 引起的内存需求,导致整个系统变慢。

对内存的竞争不仅存在于不同的用户程序之间。有时,操作系统会对自己发动一场内战。现代操作系统使用统一页面缓存(unified page cache),意味着同一块物理内存既用于应用程序数据(匿名页面),也用于缓存来自磁盘的文件。一个试图提供帮助的激进文件系统可能会成为问题。例如,一个后台进程读取一个大文件可能会触发操作系统进行“预读(read ahead)”,即推测性地获取它认为很快会需要的文件部分。如果这种预读过于激进,它会用文件数据填满内存,迫使操作系统驱逐一个活跃前台应用程序的关键工作集页面。用户看到他们的交互式程序陷入停顿,成为操作系统过度热情和不协调的帮助行为的受害者。调整系统需要仔细平衡这些相互竞争的子系统之间的内存预算,确保一个子系统的效率不会导致另一个子系统的颠簸。

当你的电脑感觉迟钝时,你体验到的往往就是这场战斗。运行的不仅仅是你的主应用程序,还有一系列后台守护进程:为搜索索引文件的服务、检查软件更新的服务,或者将数据同步到云端的服务。虽然单个来看它们很小,但它们集体的内存占用可能相当可观。当你启动一个消耗大量内存的应用程序时,你的应用程序加上所有这些守护进程的总需求可能会超过可用的 RAM。系统开始颠簸,但该怪谁呢?一个智能的操作系统可以监控每个进程的缺页频率(Page Fault Frequency, PFF)。它可以发现,虽然你的前台应用程序的工作集增长了,但现在是那些后台守护进程在过度地发生缺页中断,无法将它们自己的工作集保留在内存中。正确的反应不是惩罚所有进程,而是进行一种分类处理:暂时挂起那些低优先级、高缺页率的后台任务,从而减少整体内存压力,让系统得以稳定。

当应用程序自造交通拥堵

颠簸的原理是如此基本,以至于即使我们移出操作系统的直接控制范围,它们也会重现。像数据库系统或 Web 缓存这样的大型复杂应用程序通常会自己管理内存,实际上是创建了一个私有的、应用级别的虚拟内存系统。而在这样做的时候,它们常常会重新发现完全相同的问题。

考虑一个大型数据库管理系统(DBMS)。它在内存中维护一个“缓冲池(buffer pool)”,这是它自己私有的磁盘块缓存。数据库的经典工作负载涉及两种流量类型的混合:短促、快速的查询,访问一小部分频繁使用的“热点集(hot set)”数据(如用户个人资料),以及长的顺序扫描,读取整个表(如生成月度报告)。一个朴素的最近最少使用(LRU)替换策略,对于单独的热点集工作得很好,但对于这种混合工作负载来说可能是灾难性的。顺序扫描用一连串只使用一次的页面淹没了缓冲池。这些一次性使用的页面将宝贵的、频繁使用的热点集页面挤出缓冲区。数据库随后开始在其热点数据上发生未命中,性能随之崩溃。这就是缓冲池颠簸(buffer pool thrashing)。解决方案要求应用程序比通用操作系统更聪明。它必须利用其领域知识来区别对待不同类型的内存访问,例如,通过防止扫描的页面污染缓冲池,或通过限制并发扫描的数量——这一行为直接类似于操作系统为停止颠簸而降低多道程序度。

同样的故事也发生在为 Web 提供支持的缓存中。一个内容分发网络(CDN)可能有一个可以容纳 CCC 个项目(图像、视频等)的缓存。如果当前互联网上“热门”或流行的项目集合 NhN_hNh​ 大于缓存的容量(Nh>CN_h > CNh​>C),一个简单的 LRU 缓存就会发生颠簸。命中率本应很高,因为大多数请求都是针对热门项目,但现在却崩溃了。一个项目被获取,但在它能被再次请求之前,就被 CCC 个同样被请求的其他热门项目挤出去了。缓存把所有时间都花在重新获取它最近才持有的项目上。解决方法再次是,要么增加资源(C≥NhC \ge N_hC≥Nh​),要么更聪明地管理负载,例如,使用只缓存已证明受欢迎的项目的准入控制策略。

现代的颠簸:数据科学与云

当我们来到计算技术的前沿时,规模和复杂性发生了变化,但颠簸的基本主题依然存在,并以新颖而迷人的形式出现。

现代机器学习(ML)工作负载通常是周期性的。一个训练任务可能会在数据加载阶段(从存储中读取数据批次)和计算阶段(GPU 对该数据进行处理)之间交替。这两个阶段有不同的工作集。如果计算阶段的模型和数据加载阶段的缓冲区的组合内存占用超过了物理 RAM,系统就会进入周期性颠簸状态。每当任务从计算切换到加载时,它都必须将数据页面换入,从而驱逐模型的页面。每当它切换回计算时,它又必须将模型换回,驱逐数据页面。进度慢得像爬行。解决这个问题的一个关键技术是仔细管理数据加载器的内存占用,例如,使用一个较小的、固定大小的“固定(pinned)”内存缓冲区环,这些缓冲区被锁定在 RAM 中,防止数据加载阶段蚕食计算所需的内存。

在像 MapReduce 这样的大规模分布式系统中,颠簸可能由同步引起。想象数百个任务同时启动。它们都经过一个低内存的“map”阶段,然后几乎完全同步地转换到一个高内存的“reduce”阶段。这种同步的需求造成了内存使用量的巨大峰值,压垮了工作节点,导致整个集群发生颠簸。这相当于数字世界里每个人都在下午 5:00 整离开办公室,造成交通瘫痪。一个优雅的解决方案是通过为每个任务引入一个小的、随机的启动延迟来打破这种对称性。这种“抖动(jitter)”将资源需求在时间上平滑开来,确保任务在不同时间进入其重负荷阶段,从而防止同步的需求峰值。

这些挑战在无服务器云(serverless cloud)中表现得最为明显。当一波请求冲击一个“无服务器”函数时,平台可能需要同时执行数十次“冷启动(cold starts)”。每次冷启动都涉及将函数的代码及其库加载到内存中。如果所有这些函数共享一个大型库,它们都会同时开始在其页面上发生缺页中断。这不仅造成内存压力,还制造了一场 I/O 风暴(I/O storm)。来自磁盘的总页面换入需求可能会压垮磁盘的带宽。系统不是因为内存已满而颠簸,而是因为填充内存的“管道”被堵塞了。这就是 I/O 颠簸(I/O thrashing)。解决方案直接取自颠簸的应对策略:使用准入控制来错开冷启动,限制并发 I/O 需求,或者通过在函数启动之前将共享库加载到内存中来“预热”系统。

最后,让我们考虑在虚拟化环境中发生的终极“俄罗斯套娃”式颠簸。一台宿主机运行多个虚拟机(VM),每个虚拟机都有自己的客户机操作系统。宿主机的 hypervisor 可能会尝试使用“气球驱动(balloon driver)”从 VM 回收内存。但如果做得过于激进,hypervisor 可能会回收过多内存,以至于 VM 的分配量低于其工作集。客户机操作系统不知道外部世界发生了什么,只看到自己的内存神秘地缩小并开始颠簸,将其自己的页面交换到其虚拟磁盘上。但这个虚拟磁盘只是宿主机上的一个文件!客户机疯狂的交换行为转化为宿主机上的巨大 I/O 负载。这种 I/O 负载反过来又可能导致宿主机自身的内存缓冲区膨胀,将宿主机本身推入颠簸。这种级联故障,即客户机颠簸引发宿主机颠簸,是一场可怕的“交换风暴(swap storm)”,可以使整个服务器宕机。这是对现代系统中资源争用复杂、分层特性的一个有力教训。

从 CPU 缓存到全球云,故事都是一样的。当对一种资源的活跃需求超过其容量,并且存在一个朴素的替换策略时,系统就可能进入一种不断搅动、生产力低下的病态。理解这个简单而普遍的原理,是设计出不仅功能强大,而且在压力下也能保持优雅的系统的第一步。