try ai
科普
编辑
分享
反馈
  • 抢占式内核:并发、响应性与实时系统

抢占式内核:并发、响应性与实时系统

SciencePedia玻尔百科
  • 抢占式内核通过使用硬件定时器来强制中断任务,从而确保系统响应性,这与任务自愿让出控制权的协作式系统形成对比。
  • 内核操作在一个“原子上下文”中受到抢占计数的保护,这可以防止调度器中断关键的数据操作,从而确保系统稳定性。
  • 自旋锁(非休眠,用于原子上下文)和互斥锁(可休眠,用于可抢占上下文)之间的选择,取决于“禁止在原子上下文中休眠”这一基本规则。
  • 抢占模型在延迟和吞D量之间创造了一种权衡,这影响了从响应式桌面到高吞吐量计算和硬实时系统等各种应用的系统设计。

引言

在我们与计算机的日常交互中,我们想当然地认为可以同时运行多个应用程序。网页浏览器、音乐播放器和文档编辑器似乎都能完美和谐地工作,创造出一种无缝的多任务体验。这种并发的错觉是现代操作系统最伟大的成就之一,但它也提出了一个根本性问题:单个处理器如何管理如此多对其注意力的竞争需求?答案就在于操作系统的内核及其调度任务的方法,这一设计选择深刻影响着系统的性能、响应性和可靠性。本文将深入探讨应对这一挑战的主流范式:抢占式内核。

本文的探索分为两部分。首先,在“原理与机制”部分,我们将剖析实现抢占的核心概念。我们将探讨内核如何安全地中断任务,如何使用原子上下文和锁来保护其自身的数据结构,以及如何解决像优先级反转这样的复杂挑战。其次,在“应用与跨学科联系”部分,我们将看到这些原理的实际应用。我们将考察由抢占式内核管理的吞吐量和延迟之间的权衡,如何塑造了桌面用户体验、服务器在攻击下的弹性,以及实时和高性能计算系统所需的关键保证。

原理与机制

想象你正在一场盛大宴会上,宴会只有一位技艺精湛的厨师。这位厨师可以烹饪任何可以想象的菜肴,但有一个奇怪的限制:他一次只能做一道菜。如果你有一百位客人,都点了不同的东西,你如何营造出每个人都同时得到服务的错觉?你需要一位出色的餐厅领班,他可以指挥厨师做一分钟汤,然后切换到烤肉做两分钟,再准备一点沙拉,如此反复。通过在任务之间快速切换,领班创造了并行工作的表象,确保没有客人等待太久。

在计算机世界里,中央处理器(CPU)就是我们那位专注的厨师,而操作系统的​​调度器​​就是餐厅领班。在单个CPU核心上同时运行音乐播放器、网页浏览器和文字处理器的魔力,正是这种并发的错觉。但这引出了一个深刻的问题:调度器如何决定何时切换?这个问题将我们引向操作系统中最基本的设计选择之一:协作式内核与抢占式内核的区别。

礼貌的系统 vs. 强权的系统

早期的多任务系统是​​协作式​​的。这就像一场礼貌的对话,每个参与者在表达完自己的观点后都会自愿让出发言权。一个程序会一直运行,直到它到达某个点,自己决定“我现在完成了”,然后将控制权交还给调度器。这种方式简单,但很脆弱。如果一个有 bug 或恶意的程序从不让出控制权怎么办?整个系统就会冻结,等待一个永远不会到来的轮次。

因此,现代操作系统是​​抢占式​​的。它们就像一场有严格计时员的辩论。硬件定时器以固定且频繁的间隔(可能每隔几毫秒)中断CPU。当定时器“响起”时,调度器得以运行,并可以强制停止当前任务——即抢占它——然后切换到另一个任务。这确保了公平性和响应性。即使一个程序陷入无限循环,调度器仍然可以为其他应用程序分配时间,从而保持系统存活。

但这种中断——即抢占——的权力不能被滥用。你可以在任何时刻中断一个任务吗?想象一位外科医生正在进行精细的缝合。你不会就这么拍拍他的肩膀,让他换到另一个病人那里去。内核也有其进行精细操作的时刻。

中断的规则:原子上下文

当内核在操作其自身的核心数据结构时——比如正在运行的进程列表或网络缓冲区——它正处于一个​​临界区​​。如果它在这样的更新过程中被抢占,而新的任务试图读取同样(此时已不一致)的数据,混乱就会随之而来。这些操作必须是​​原子性​​的,意味着它们必须看起来是一次性完成的,没有任何中断。

为了管理这一点,可抢占内核采用了一种简单而强大的机制:​​抢占计数​​。你可以把它想象成酒店房间门上的“请勿打扰”标志。当内核进入临界区时,它会增加这个计数器。离开时,则会减少它。规则很简单:只有当抢占计数为零时,调度器才被允许执行抢占。如果一个任务在 preempt_count > 0 的情况下运行,它就被称为处于​​原子上下文​​中,并且不能被调度器抢占。这个简单的计数器是安全内核抢占的基石,确保敏感操作不会被调度器的干预所撕裂。

两种抢占的故事

在这里我们必须谨慎用词,因为“抢占”可以指代两种截然不同的现象。

首先,是​​硬件抢占​​,这就像一个意料之外的紧急电话。外部设备——你的键盘、网卡、系统定时器本身——可以向CPU发送一个​​中断请求(IRQ)​​。如果CPU启用了中断,它别无选择,只能立即停止当前工作,保存现场,并运行一个名为​​中断处理程序​​的特殊函数来服务该请求。这是一种在硬件层面发生的极其强力的抢占,不受调度器意愿的影响。在大多数内核中,这种中断上下文被视为神圣的领域,并优先于任何正在运行的线程,无论其优先级如何。

其次,是​​调度器抢占​​,这是我们在前面讨论过的,在任务之间进行切换的软件层决策。这就是抢占计数所控制的。

这种区别催生了内核可以使用的两种不同的“请勿打扰”标志:

  1. ​​禁用中断​​ (local_irq_disable()):这是最强大的标志,相当于说:“连电话都不要接。”它屏蔽了本地CPU上的硬件中断。在保护普通内核代码和中断处理程序之间共享的数据时,这绝对是必要的。如果你不这样做,中断可能恰好在你更新的中途到达,处理程序将会看到一个被破坏的、半成品的状态,导致严重的数据竞争。

  2. ​​禁用抢占​​ (preempt_disable()):这是一个更微妙的标志,相当于说:“只是别切换我的主要任务。”它增加抢占计数,告诉调度器退后,但它让硬件中断保持启用。系统仍然可以即时响应按键或网络数据包。这是保护仅在不同线程之间共享(但不与中断处理程序共享)的数据的首选方法,因为它在确保对其他线程的原子性的同时,保持了系统的响应性。

首恶:在原子上下文中休眠

现在,让我们结合这些概念,揭示可抢占内核编程最基本的规则。如果一个线程处于原子上下文(preempt_count > 0)中,并试图做一些可能耗时很长的事情,比如从慢速磁盘读取文件,会发生什么?它不能只是坐在那里等待,因为那会拖慢CPU。它需要​​休眠​​——也就是自愿放弃CPU,并请求调度器在数据准备好时唤醒它。

但要进入休眠状态,它必须调用调度器!而调度器的首要规则是什么?如果 preempt_count > 0,它就不能运行。这是一个悖论,一个会将系统拖垮的逻辑矛盾。这就是首恶:​​绝不能在原子上下文中调用可能导致休眠的函数​​。

这条规则是内核中存在两类锁的原因:

  • ​​自旋锁​​:这些是非休眠锁。如果一个线程试图获取一个已经被持有的自旋锁,它不会休眠。它会在一个紧凑的循环中“自旋”,消耗CPU周期,反复检查锁直到它被释放。因为它从不休眠,所以在原子上下文中使用自旋锁是安全的。这也意味着受自旋锁保护的代码必须极其快,因为长时间持有它会冻结该CPU。由自旋锁引起的延迟至少受其临界区长度的限制。

  • ​​互斥锁​​:这些是可休眠锁。如果一个线程试图获取一个有竞争的互斥锁,它会注册其意图并请求调度器将其置于休眠状态。这比自旋效率高得多,因为另一个线程可以使用CPU。然而,由于它们会调用调度器,互斥锁只能在完全可抢占的上下文(preempt_count = 0)中使用。

这条规则至关重要,以至于不遵守它可能导致系统编程中最令人沮丧的一些 bug。开发人员可能会编写代码,在自旋锁保护的区域内非法调用休眠函数。在负载较低的开发机上,导致休眠的条件可能永远不会发生,bug 也就隐藏了起来。但一旦部署到繁忙的服务器上,竞争加剧,休眠路径被触发,系统就会崩溃,并报出神秘的“在无效上下文中调用了休眠函数”错误。调试这个问题需要理解这一基本原则,并仔细重构代码,在尝试任何阻塞操作之前释放所有非休眠锁。

当优先级出错:优先级反转的威胁

拥有一个具有明确优先级的抢占式系统似乎是实现响应性的完美解决方案。如果一个高优先级任务需要运行,它就应该运行。但现实世界是混乱的,共享资源可能导致一种令人困惑且危险的现象:​​优先级反转​​。

想象一下这个曾困扰火星探路者号(Mars Pathfinder)任务的著名场景:一个高优先级任务(THT_HTH​,如主控制循环)、一个低优先级任务(TLT_LTL​,用于遥测数据)和一个中优先级任务(TMT_MTM​,如科学实验)正在运行。THT_HTH​ 和 TLT_LTL​ 共享一个由互斥锁保护的资源。

  1. 在 t=0t=0t=0 时,TLT_LTL​ 启动,锁住互斥锁,并开始工作。
  2. 在 t=1.0 mst=1.0\,\text{ms}t=1.0ms 时,重要任务 THT_HTH​ 唤醒。它抢占了 TLT_LTL​。但它立即需要那个被 TLT_LTL​ 持有的互斥锁。因此,THT_HTH​ 阻塞,等待 TLT_LTL​ 完成。
  3. 在 t=1.1 mst=1.1\,\text{ms}t=1.1ms 时,任务 TMT_MTM​ 唤醒。调度器环顾四周:THT_HTH​ 被阻塞,在就绪的任务(TMT_MTM​ 和 TLT_LTL​)中,TMT_MTM​ 的优先级更高。
  4. 于是,TMT_MTM​ 开始运行。它不停地运行。它阻止了低优先级任务 TLT_LTL​ 获得任何CPU时间来完成其工作并释放互斥锁。

结果如何?高优先级任务 THT_HTH​ 被卡住等待,不是等待它所依赖的低优先级任务,而是等待一个完全不相关的中优先级任务。这就是优先级反转。在探路者号的案例中,它导致整个系统反复重启。

解决方案和问题本身一样巧妙:​​优先级继承​​。这是高级实时内核的一个关键特性。当 THT_HTH​ 因等待 TLT_LTL​ 持有的互斥锁而阻塞时,系统会暂时将 THT_HTH​ 的高优先级“借给”TLT_LTL​。现在,当 TMT_MTM​ 唤醒时,调度器看到 TLT_LTL​ 正在以提升的高优先级运行,并拒绝让 TMT_MTM​ 抢占它。TLT_LTL​ 迅速完成其工作,释放互斥锁(以及其借来的优先级),并允许 THT_HTH​ 以最小的延迟继续进行。

实时前沿

这种抢占、原子性和优先级的复杂舞蹈不仅仅是学术演练。对于你的桌面电脑来说,这是流畅用户体验与卡顿、令人沮丧体验之间的区别。但对于​​实时系统​​而言,这关乎绝对的、可预测的正确性。

考虑一条装配线上的机械臂或汽车的防抱死制动系统。这些都是​​硬实时​​任务:它们必须在严格的截止时间之前完成计算。错过截止时间不是一个小故障;它是一次灾难性的失败。

如果这样的系统使用一个有很长非抢占部分的内核,一个高优先级的制动控制任务可能会就绪,却发现内核正忙于为低优先级任务做一些不可中断的事情。如果这个延迟,即所谓的​​阻塞时间​​,过长,截止时间就会被错过。一个简单的分析表明,一个具有 3.5 ms3.5\,\text{ms}3.5ms 内核临界区的自愿抢占模型,可能导致一个截止时间为 5 ms5\,\text{ms}5ms 的高优先级任务失败,而一个最大非抢占区域仅为 0.05 ms0.05\,\text{ms}0.05ms 的完全抢占式内核则能让它轻松成功。

这就是抢占式内核的最终目的。它是一台复杂的、精心构造的机器,旨在驯服并发的混乱,不仅提供同时做多件事情的错觉,而且保证在此时此刻做最重要的事情,并具有坚定不移的可预测性。这是一个美丽的证明,展示了几个简单的规则——关于何时中断、何时等待、轮到谁——如何能够催生出具有非凡能力和可靠性的系统。

应用与跨学科联系

在我们穿越内核复杂机制(从自旋锁到调度器)的旅程之后,你可能会有一种类似于拆解一块精美瑞士手表的感觉。我们已经看到了所有的齿轮和弹簧,但真正的魔力在于看着它们协同工作来报时。现在,让我们把手表重新组装起来,看看内核抢占这个看似深奥的操作系统设计细节,如何在现代计算的宏大舞台上发挥作用。你会发现,它不仅仅是一个技术脚注,而是定义我们数字体验的各种权衡的核心,从视频游戏的流畅度到国家电网的可靠性。

抢占的故事是一个关于根本冲突的故事,这种张力存在于每台计算机中:​​吞吐量​​与​​延迟​​之间的斗争。吞吐量是完成大量工作的渴望,是处理堆积如山的数据,是渲染一个复杂的场景。延迟是即时响应的渴望,是让系统立刻做出反应。一台纯粹为吞吐量优化的计算机会是一头强大但迟缓的猛兽,它会埋头处理任务,而不顾你紧急的鼠标点击。一台纯粹为延迟优化的计算机可能感觉反应迅速,但在任何重负载下都会崩溃。抢占式内核的艺术在于驾驭这种冲突,成为一个指挥各种竞争需求的杰出指挥家。

桌面的交响乐:让我们的感官愉悦

让我们从我们最直接体验的世界开始:桌面。你移动鼠标,指针在屏幕上毫不费力地滑动。你打字,字母立即出现。你播放一首歌曲,音乐流畅无阻,没有一丝爆音或卡顿。这种无缝的体验是一种精心制作的错觉,而抢占式内核是首席魔术师。

想象一下你的电脑正在后台执行一项艰巨的任务,比如复制一个巨大的文件。在旧系统中,这可能涉及一系列复杂的“写时复制”操作,内核在这些操作中忙于管理内存页面。使用一个简单的、非抢占式的内核,CPU会被锁在内核的私人工作室里,勤奋地复制数据,对外界充耳不闻。如果在这期间你移动了鼠标,来自输入设备的请求会到达,但内核实际上会说:“我很忙,请稍等。”结果呢?光标冻结。你的图形界面,这个通往你数字世界的窗口,会变得没有响应,直到后台任务完成。

这就是​​完全抢占式内核​​施展其魔法的地方。它建立了一条规则:大多数内核代码都可以被中断。当你的高优先级图形合成器线程需要运行时,以更新屏幕,它被允许礼貌地(或不那么礼貌地!)拍拍那个正在复制内存的例程的肩膀说:“不好意思,我现在需要CPU。”低优先级的工作被暂时暂停,屏幕得到更新,流畅的错觉得以维持。唯一不能被中断的部分是微小的、定义明确的临界区——比如锁定一个数据结构——这些临界区只持续几十微秒。这确保了繁重的后台任务最多只会造成难以察觉的亚毫秒级延迟,而不是令人沮丧的数秒冻结。

这个原则从视觉延伸到了听觉领域。要使数字音频系统正常工作,它必须以精确、周期性的间隔——可能每5毫秒——向声卡提供一个新的音频数据缓冲区。如果迟到了,声卡的数据就会耗尽,你会听到可闻的“咔嗒”声或“爆音”,这种现象被称为 xrun(音频下溢)。这是一个显而易见的错过截止时间的例子。通过分析可用的时间“预算”,我们可以看到​​实时(RT)抢占式内核​​给了我们最大的信心。通过将大多数锁转换为可抢占的互斥锁,甚至将中断处理转变为可调度的线程,RT内核极大地缩小了系统的非抢占部分。这为延迟提供了最严格的保证,确保我们的音频回调函数即使在系统压力下也几乎总能满足其截止时间,从而保持音乐流畅播放。

当世界来敲门:在数据洪流中幸存

我们的计算机并非孤立存在。它们不断地进行通信,被来自网络和存储设备的数据所围困。在这里,抢占不仅仅是获得愉快体验的工具;它是一种至关重要的生存机制。

考虑一次分布式拒绝服务(DDoS)攻击,服务器每秒被数百万个网络数据包淹没。一个天真的内核可能会尝试处理每一个到达的数据包。CPU将完全饱和于运行网络中断处理程序,这种状态被称为“活锁”。机器在技术上是“活着的”——疯狂地处理数据包——但对任何其他命令都完全没有响应。你甚至无法登录来诊断问题。系统忙于工作,以至于没有时间做有用的工作。

现代可抢占内核采用了一种绝妙的策略来对抗这种情况。使用一种称为NAPI的机制,网络驱动程序在即时的、非抢占的中断上下文中处理一个固定的、小“预算”的数据包。如果数据包风暴如此之大,以至于预算耗尽但仍有更多工作要做,内核会做一个聪明的举动:它将剩余的、压倒性的工作负载推给一个常规的内核线程(ksoftirqd)。这个线程以正常优先级运行,并且至关重要的是,它是​​完全可抢占的​​。

效果是深远的。当管理员试图输入命令来对抗攻击时,那个交互式输入任务被赋予了更高的优先级。它可以——而且确实会——抢占那个正在努力排空数据包海洋的 ksoftirqd 线程。系统做出了一个有意识的选择:它将管理员的命令置于攻击者的数>据包之上。这个选择的代价是网络缓冲区会溢出,大部分攻击数据包将被丢弃。但这正是我们想要的!内核牺牲了吞吐量(处理每个数据包)来保留延迟(响应用户),从而允许系统即使在极端胁迫下也能保持可管理性,。

类似的剧情也发生在存储上。当你保存文件时,现代文件系统首先会写入日志以确保安全。这涉及CPU密集型工作(如计算校验和)和慢速的I/O密集型工作(写入磁盘)。一个非抢占式内核可能会在整个操作期间持有锁,从而冻结交互性。然而,一个设计良好的可抢占内核理解其中的区别。它只在短暂的、真正关键的CPU密集型部分持有锁。在等待磁盘旋转的漫长、数毫秒的时间里,内核可以自由地调度其他任务。这种精妙的舞蹈——将非抢占区域最小化到绝对必要的范围——正是让你的系统即使在忙于向磁盘写入数据时也能感觉响应迅速的原因。如果搞错了这一点,例如在单核系统上允许在持有原始自旋锁时进行抢占,可能会导致立即的死锁——一种灾难性的失败,系统会因等待一个永远无法被释放的锁而停止运行。

极端情况:吞吐量为王或迟到即失败

虽然响应迅速的桌面是一个普遍目标,但抢占并非一刀切的解决方案。在某些领域,吞吐量与延迟的权衡被推向了一个或另一个极端。

在高性能计算(HPC)中,科学家们在数千个核心上运行可能需要数天或数周的大规模模拟。这些应用程序经过精细调整,其性能通常受限于CPU从其缓存中访问数据的速度。一次上下文切换——内核抢占模拟以运行另一个任务,哪怕只是一瞬间——都是毒药。它会驱逐模拟宝贵的CPU缓存数据(一种“缓存污染”效应),当模拟恢复时,它必须花费数千个周期来缓慢地重新填充它们。对于这些工作负载,吞吐量为王,延迟是次要问题。因此,HPC集群通常运行的内核要么完全禁用抢占(CONFIG_PREEMPT_NONE),要么只在少数明确定义的点允许抢占(CONFIG_PREEMPT_VOLUNTARY)。为了提高后台守护进程的响应性而引入的几个额外上下文切换的成本,可能会显著降低主计算的性能。在这个世界里,我们有意识地选择牺牲一些响应性,以换取那最后一点计算吞吐量的百分比。

在光谱的另一端是​​硬实时系统​​。想象一下飞机上的飞行控制器、核反应堆中的控制系统,或者装配线上的精密机器人。在这些系统中,错过截止时间不是不便;它是一次潜在的灾难性失败。这些系统依赖于像最早截止时间优先(EDF)这样的调度理论,该理论可以提供数学证明,证明只要总利用率低于100%且系统是完全可抢占的,所有截止时间都将被满足。

但在这里,魔鬼藏在内核的细节里。一个标准的内核,即使是“可抢占”的,也包含非抢占部分。正如我们可以通过一个简单的场景所展示的,如果一个低优先级任务在一个高优先级任务(带有紧急截止时间)就绪之前进入了一个非抢占的内核部分,高优先级任务就会被阻塞。它必须等待。这个由非抢占部分造成的阻塞时间,可以打破调度器优雅的数学保证,导致即使CPU平均不是很忙,截止时间也会被错过。这就是为什么针对Linux的 PREEMPT_RT 补丁是一项如此巨大的工程努力。它是一项对整个内核进行艰苦的审计和重新设计的工作,旨在找出并缩小每一个非抢占部分,将自旋锁变为可抢占的互斥锁,并将中断线程化,所有这一切都是为了一个英雄般的目标:让现实世界的内核尽可能地接近硬实时保证所需的理想化、完美可抢占的模型。

追求完美:驯服抖动

对于最高要求的应用,如高频交易或专业音频,仅仅满足截止时间还不够。关键的关注点是​​抖动​​——即延迟在不同时刻的变化。平均响应时间为1毫秒并不能给人带来多少安慰,如果百分之一的情况下,响应需要10毫秒。这种“尾延迟”——即第99或99.9百分位的行为——是一个关键的服务质量(QoS)指标。

在这里,我们再次看到了抢占模型中清晰的层次结构。一个自愿式(很大程度上是非抢占式)的内核可能平均延迟较低,但偶尔会碰到一个长的内核路径,从而产生大的尾延迟。一个可抢占的内核削减了大部分这些长路径,使分布更加紧凑。一个 PREEMPT_RT 内核更进一步,使得行为异常一致和可预测,从而极大地缩小了第99百分位的响应时间。

在这场追求中的最后前沿是消除内核自身的心跳作为抖动的来源。传统内核使用周期性的定时器滴答来处理计时和调度。即使这个滴答很短暂,它也是一种中断。对于一个超低延迟的任务来说,这是一种不受欢迎的干扰。一个​​无滴答内核​​(CONFIG_NO_HZ)巧妙地在CPU空闲时抑制这个滴答,防止它干扰一个即将被唤醒的任务。但为了实现终极的低抖动执行,一个更高级的功能(CONFIG_NO_HZ_FULL)可以在一个隔离的CPU核心上使用,以完全停止滴答,即使在用户任务运行时也是如此。这与 PREEMPT_RT 内核相结合,创造了一个极其安静的环境,允许单个关键应用程序以最少的操作系统干扰运行。

从光标的流畅移动到发电厂的坚定稳定,内核抢占模型的选择深刻地表明了我们所珍视的东西。它是平衡的艺术,是性能与响应性之间持续的协商。它向我们展示,操作系统不是一套僵化的规则,而是一个灵活且极具智能的框架,旨在管理计算中最根本的冲突之一。