FreeBSD 中文社区 2025 第二季度问卷调查
FreeBSD 中文社区(CFC)
VitePress 镜像站QQ 群 787969044视频教程Ⅰ视频教程Ⅱ
  • FreeBSD 从入门到追忆
  • 中文期刊
  • 状态报告
  • 发行说明
  • 手册
  • 网络文章集锦
  • 笔记本支持报告
  • Port 开发者手册
  • 架构手册
  • 开发者手册
  • 中文 man 手册
  • 文章与书籍
  • UNIX 四分之一世纪
  • Unix 痛恨者手册
  • FreeBSD 架构手册翻译项目
  • 商标
  • 概述
  • 第一部分 内核
    • 第 1 章 引导和内核初始化
    • 第 2 章 内核中的锁
    • 第 3 章 内核对象
    • 第 4 章 jail 子系统
    • 第 5 章 SYSINIT 框架
    • 第 6 章 TrustedBSD MAC 框架
    • 第 7 章 虚拟内存系统
    • 第 8 章 SMPng 设计文档
    • 第 9 章 编写 FreeBSD 设备驱动程序
    • 第 10 章 ISA 设备驱动程序
    • 第 11 章 PCI 设备
    • 第 12 章 公共存取模型 SCSI 控制器
    • 第 13 章 USB 设备
    • 第 14 章 Newbus
    • 第 15 章 声音子系统
    • 第 16 章 PC 卡
  • 第二部分 附录
    • 参考文献
由 GitBook 提供支持
LogoLogo

FreeBSD 中文社区(CFC) 2025

在本页
  • 8.1. 介绍
  • 8.2. 基本工具和锁定基础
  • 8.2.1. 原子指令和内存屏障
  • 8.2.2. 读锁与写锁
  • 8.2.3. 锁定条件与结果
  • 8.3. 一般架构与设计
  • 8.3.1. 中断处理
  • 8.3.2. 内核抢占与临界区
  • 8.3.3. 线程迁移
  • 8.3.4. 回调
  • 8.4. 特定的锁策略
  • 8.4.1. 凭证
  • 8.4.2. 文件描述符和文件描述符表
  • 8.4.3. Jail 结构
  • 8.4.4. MAC 框架
  • 8.4.5. 模块
  • 8.4.6. Newbus 设备树
  • 8.4.7. 管道
  • 8.4.8. 进程和线程
  • 8.4.9. 调度器
  • 8.4.10. Select 和 Poll
  • 8.4.11. SIGIO
  • 8.4.12. Sysctl
  • 8.4.13. Taskqueue
  • 8.5. 实现说明
  • 8.5.1. 睡眠队列
  • 8.5.2. 旋转门(Turnstiles)
  • 8.5.3. 互斥锁实现的细节
  • 8.5.4. 见证(Witness)
  • 8.6. 杂项话题
  • 8.6.1. 中断源和 ICU 抽象
  • 8.6.2. 其他随机问题/话题
  • 术语表
在GitHub上编辑
导出为 PDF
  1. 第一部分 内核

第 8 章 SMPng 设计文档

上一页第 7 章 虚拟内存系统下一页第 9 章 编写 FreeBSD 设备驱动程序

最后更新于26天前

8.1. 介绍

本文档介绍了当前的 SMPng 架构的设计和实现。首先,介绍了基本的原语和工具。接着,概述了 FreeBSD 内核的同步和执行模型的总体架构。然后,讨论了特定子系统的锁策略,记录了为每个子系统引入细粒度同步和并行性的方式。最后,提供了详细的实现说明,解释了设计选择,并使读者了解使用特定原语时的重要影响。

本文档仍在不断完善中,将会根据 SMPng 项目的设计和实现进展进行更新。目前许多部分仅以大纲形式存在,但随着工作推进会进一步详细化。有关文档的更新或建议可以发送给文档编辑者。

SMPng 的目标是允许内核中的并发。内核本质上是一个相当庞大且复杂的程序。为了使内核支持多线程,我们使用了一些使其他程序支持多线程的工具。这些工具包括互斥锁、共享/独占锁、信号量和条件变量。有关这些和其他 SMP 相关术语的定义,请参阅本文的 部分。

8.2. 基本工具和锁定基础

8.2.1. 原子指令和内存屏障

关于内存屏障和原子指令已经有了若干现有的处理方式,因此本节不会涉及过多的细节。简而言之,如果一个变量的写操作需要锁保护,那么不能在没有锁的情况下读取该变量。这一点通过内存屏障的作用变得显而易见;内存屏障仅仅确定内存操作的相对顺序,并不保证内存操作的时序。也就是说,内存屏障不会强制刷新 CPU 的本地缓存或存储缓冲区。相反,锁释放时的内存屏障确保所有对受保护数据的写入对其他 CPU 或设备可见,只要释放锁的写入操作对其他 CPU 可见。CPU 可以将该数据保持在其缓存或存储缓冲区中,直到它想要时才刷新。然而,如果另一个 CPU 对同一数据执行原子指令,首先执行的 CPU 必须确保更新后的值对第二个 CPU 可见,并且与内存屏障可能要求的其他操作一同进行。

例如,假设一个简单模型,其中数据在主内存(或全局缓存)中时才被认为是可见的。当一个 CPU 触发原子指令时,其他 CPU 的存储缓冲区和缓存必须刷新对相同缓存行的任何写入,并且将任何待处理操作一起提交到内存屏障之后。

这就要求在使用受原子指令保护的项时要特别小心。例如,在 sleep 互斥锁实现中,我们必须使用 atomic_cmpset 而不是 atomic_set 来开启 MTX_CONTESTED 位。原因在于我们将 mtx_lock 的值读取到一个变量中,然后根据该读取值做出决策。然而,我们读取到的值可能已经过时,或者在我们做出决策的过程中发生了变化。因此,当 atomic_set 执行时,它可能会在不同的值上设置该位,而非我们做决策时使用的值。因此,我们必须使用 atomic_cmpset,只有在我们做决策时所用的值是最新且有效时才会设置该值。

最后,原子指令仅允许对一个项进行更新或读取。如果需要原子地更新多个项,那么必须使用锁来保护。例如,如果两个计数器必须被读取,并且它们的值相对于彼此保持一致,那么这两个计数器必须通过锁来保护,而不是通过单独的原子指令。

8.2.2. 读锁与写锁

读锁不需要像写锁那样强大。两种类型的锁都需要确保它们访问的数据不是陈旧的。然而,只有写访问才要求独占访问。多个线程可以安全地读取一个值。使用不同类型的锁来区分读取和写入可以通过多种方式实现。

首先,sx 锁可以通过在写入时使用独占锁,读取时使用共享锁来实现。这种方法相当直接。

第二种方法略微复杂一些。你可以使用多个锁来保护一个数据项。然后,在读取该数据时,你只需要获取其中一个锁的读锁。然而,要写入数据,你需要获取所有锁的写锁。这可能使得写入操作变得昂贵,但对于数据以多种方式访问的情况很有用。例如,父进程指针由 proctree_lock sx 锁和每个进程的互斥锁共同保护。有时,使用进程锁会更容易,因为我们只是检查一个已经被锁定的进程的父进程是谁。然而,在其他地方,如 inferior,需要通过父进程指针遍历进程树,并且锁定每个进程会带来高昂的代价,同时还要保证在检查条件和根据检查结果采取行动时条件保持有效。

8.2.3. 锁定条件与结果

如果需要一个锁来检查变量的状态,以便根据读取的状态采取行动,那么你不能只在读取变量时持有锁,然后在执行操作之前释放锁。一旦你释放了锁,变量可能已经发生变化,从而使你的决策无效。因此,你必须在读取变量时持有锁,并在执行根据读取结果采取的操作时仍然保持锁定。

8.3. 一般架构与设计

8.3.1. 中断处理

遵循其他多线程 UNIX® 内核的模式,FreeBSD 通过为中断处理程序提供自己的线程上下文来处理它们。为中断处理程序提供上下文允许它们在锁上阻塞。然而,为了避免延迟,中断线程以实时内核优先级运行。因此,中断处理程序不应执行过长时间,以避免饿死其他内核线程。此外,由于多个处理程序可能共享一个中断线程,因此中断处理程序不应睡眠或使用可睡眠锁,以避免饿死另一个中断处理程序。

FreeBSD 中当前的中断线程被称为重型中断线程。之所以称之为重型,是因为切换到中断线程涉及完整的上下文切换。在最初的实现中,内核是非抢占式的,因此中断一个内核线程时,必须等到内核线程阻塞或返回用户空间后,才能有机会运行中断。

为了处理延迟问题,FreeBSD 内核已经被改为抢占式。目前,只有在释放睡眠互斥锁或中断发生时,我们才会抢占一个内核线程。然而,计划是使 FreeBSD 内核完全抢占式,如下文所述。

并非所有中断处理程序都在线程上下文中执行。相反,某些处理程序直接在主中断上下文中执行。这些中断处理程序目前被误称为“快速”中断处理程序,因为早期内核中使用的 INTR_FAST 标志用于标记这些处理程序。当前,只有时钟中断和串行 I/O 设备中断使用这些类型的中断处理程序。由于这些处理程序没有自己的上下文,因此它们不能获取阻塞锁,因此只能使用自旋互斥锁。

最后,在 MD 代码中可以添加一种可选的优化,称为轻量级上下文切换。由于中断线程在内核上下文中执行,它可以借用任何进程的 vmspace。因此,在轻量级上下文切换中,切换到中断线程时不会切换 vmspace,而是借用被中断线程的 vmspace。为了确保被中断线程的 vmspace 不会在我们使用时消失,被中断的线程在中断线程不再借用其 vmspace 之前不能执行。这可以发生在中断线程阻塞或完成时。如果中断线程阻塞,它将使用自己的上下文在再次被调度时运行。因此,它可以释放被中断线程。

这种优化的缺点是它们非常机器特定且复杂,因此只有在带来显著性能提升时才值得付出努力。目前来看,可能还为时过早,事实上,几乎所有中断处理程序都会立即在 Giant 上阻塞,并且当它们阻塞时需要进行线程修复,这可能会影响性能。另外,Mike Smith 提出了另一种中断处理方法,其工作原理如下:

  1. 每个中断处理程序有两部分:一个谓词,在主中断上下文中运行,以及一个处理程序,在其自己的线程上下文中运行。

  2. 如果中断处理程序有谓词,当中断被触发时,谓词将被执行。如果谓词返回 true,则认为中断已完全处理,内核将返回中断。如果谓词返回 false 或没有谓词,则线程处理程序将被调度执行。

将轻量级上下文切换融入这一方案可能会变得相当复杂。由于我们可能希望在未来某个时候改用这种方案,因此最好推迟对轻量级上下文切换的工作,直到我们确定最终的中断处理架构,并且确定轻量级上下文切换是否适合其中。

8.3.2. 内核抢占与临界区

8.3.2.1. 内核抢占概述

内核抢占相对简单。基本的想法是,CPU 应该始终执行优先级最高的可用工作。至少这是理想的情况。当然,也有一些情况,实现理想状态的代价不值得追求完美。

实现完整的内核抢占是非常直接的:当你调度一个线程执行时,将其放入运行队列,检查其优先级是否高于当前执行的线程。如果是,则启动一个上下文切换,将控制权转交给该线程。

虽然锁可以在抢占的情况下保护大多数数据,但并非所有内核代码都能保证抢占安全。例如,如果一个持有自旋互斥锁的线程被抢占,而新线程尝试获取相同的自旋互斥锁,则新线程可能会永远自旋,因为被中断的线程可能永远无法执行。另外,一些代码(例如在 Alpha 上执行 exec 时为进程分配地址空间编号的代码)需要避免被抢占,因为它支持实际的上下文切换代码。这些代码段通过使用临界区来禁用抢占。

8.3.2.2. 临界区

临界区 API 的职责是防止在临界区内发生上下文切换。对于一个完全抢占的内核,除了当前线程外,任何线程的 setrunqueue 操作都是一个抢占点。其一个实现是,critical_enter 设置一个每线程的标志,并且该标志由对应的退出函数清除。如果在该标志被设置时调用了 setrunqueue,则无论新线程相对于当前线程的优先级如何,都会跳过抢占。

然而,由于临界区在自旋互斥锁中用于防止上下文切换,并且多个自旋互斥锁可能被同时获取,因此临界区 API 必须支持嵌套。因此,当前实现使用了一个嵌套计数,而不是单一的每线程标志。

为了最小化延迟,临界区内的抢占被延迟,而不是被丢弃。如果一个本应被抢占的线程在当前线程处于临界区时变得可运行,则设置一个每线程的标志,表示存在待处理的抢占。当退出最外层的临界区时,将检查该标志。如果标志被设置,则会抢占当前线程,以允许优先级更高的线程运行。

中断在自旋互斥锁方面带来了问题。如果一个低级中断处理程序需要锁,它必须确保不会中断任何需要该锁的代码,以避免可能的数据结构损坏。目前,通过 cpu_critical_enter 和 cpu_critical_exit 函数,提供了一种将该机制与临界区 API 结合使用的方法。目前,该 API 在 FreeBSD 的所有平台上禁用了中断并在需要时重新启用。这个方法可能不是最优的,但它简单易懂且容易正确实现。从理论上讲,这第二个 API 只需要在自旋互斥锁用于主中断上下文时使用。然而,为了简化代码,它被用于所有自旋互斥锁,甚至所有临界区。如果采取这种方法,可能需要将 MD API 与 MI API 分开,并仅在自旋互斥锁实现中与 MI API 一起使用。如果采用这种方法,则可能需要重命名 MD API,以表明它是一个独立的 API。

8.3.2.3. 设计权衡

如前所述,做出了一些权衡,以牺牲完美抢占的情况来换取在某些情况下可能更好的性能。

第一个权衡是抢占代码不考虑其他 CPU。假设我们有两个 CPU A 和 B,其中 A 的线程优先级为 4,B 的线程优先级为 2。如果 CPU B 使一个优先级为 1 的线程变为可运行,那么理论上,我们希望 CPU A 切换到这个新线程,以便我们能运行两个最高优先级的可运行线程。然而,确定哪个 CPU 应该执行抢占以及通过 IPI 实际通知该 CPU 的成本,以及所需的同步,都会非常巨大。因此,当前代码会强制 CPU B 切换到优先级更高的线程。请注意,这仍然将系统置于一个更好的状态,因为 CPU B 正在执行优先级为 1 的线程,而不是优先级为 2 的线程。

第二个权衡是将即时内核抢占限制为实时优先级内核线程。在上面定义的简单抢占情况下,如果一个更高优先级的线程变为可运行,线程总是会立即被抢占(或者在退出临界区后立即抢占)。然而,许多在内核中执行的线程只会在内核上下文中执行很短的时间,然后阻塞或返回到用户态。因此,如果内核抢占这些线程去运行另一个非实时优先级的内核线程,内核可能会在该线程即将休眠或执行时将其切换出去。此时,CPU 上的缓存需要调整以适应新线程。当内核返回到被抢占的线程时,它必须重新填充所有丢失的缓存信息。此外,还会执行两次上下文切换,如果内核等到第一个线程阻塞或返回到用户态再进行抢占,则可以避免这些上下文切换。因此,默认情况下,抢占代码只会立即抢占如果更高优先级的线程是实时优先级线程。

为所有内核线程启用完整的内核抢占在调试时是有价值的,因为它能暴露更多的竞态条件。它在单处理器系统(UP 系统)中特别有用,因为许多竞态条件在其他情况下很难模拟。因此,提供了一个内核选项 FULL_PREEMPTION,可以启用所有内核线程的抢占,用于调试目的。

8.3.3. 线程迁移

简而言之,当一个线程从一个 CPU 移动到另一个 CPU 时,就是线程迁移。在非抢占内核中,这只能发生在一些明确定义的点,例如调用 msleep 或返回用户态。然而,在抢占内核中,中断可能会随时强制发生抢占并可能导致迁移。这可能对每个 CPU 的数据产生负面影响,因为除了 curthread 和 curpcb 外,数据可以在迁移时发生变化。由于可以随时迁移,这使得没有保护的每个 CPU 数据访问变得几乎没有用。因此,希望能够禁用代码段的迁移,这样可以确保每个 CPU 数据的稳定性。

当前,临界区可以防止迁移,因为它不允许上下文切换。然而,这在某些情况下可能过于严格,因为临界区实际上还会阻止当前处理器上的中断线程。因此,提供了另一个 API 允许当前线程在被抢占时指示它不应迁移到另一个 CPU。

这个 API 被称为线程固定,并由调度器提供。该 API 包括两个函数:sched_pin 和 sched_unpin。这两个函数管理一个每线程的嵌套计数 td_pinned。当一个线程的嵌套计数大于零时,它被认为是固定的,而线程从零开始未固定。每个调度器实现需要确保固定的线程仅在它首次调用 sched_pin 时所在的 CPU 上执行。由于嵌套计数仅由线程本身写入,并且只有在固定线程不执行时,由其他线程读取(但在持有 sched_lock 时),因此 td_pinned 不需要任何锁。sched_pin 函数递增嵌套计数,而 sched_unpin 函数则递减嵌套计数。请注意,这些函数仅操作当前线程,并将当前线程绑定到它执行时所在的 CPU。要将任意线程绑定到特定的 CPU,应使用 sched_bind 和 sched_unbind 函数。

8.3.4. 回调

timeout 内核设施允许内核服务注册函数,以便在 softclock 软件中断期间执行。事件是基于所需的时钟滴答数来调度的,回调将大致在适当的时间调用用户提供的函数。

挂起的超时事件的全局列表由全局自旋互斥锁 callout_lock 保护;所有对超时列表的访问必须在持有此互斥锁时执行。当 softclock 被唤醒时,它会扫描挂起的超时事件列表,查找应该触发的事件。为了避免锁顺序倒转,softclock 线程在调用提供的 timeout 回调函数时会释放 callout_lock 互斥锁。如果在注册时未设置 CALLOUT_MPSAFE 标志,则在调用回调函数之前会抓取 Giant 锁,之后会释放。然后会重新抓取 callout_lock 互斥锁,继续执行。softclock 代码在释放互斥锁时非常小心,以确保列表保持一致的状态。如果启用了 DIAGNOSTIC,则会测量执行每个函数的时间,并且如果时间超过阈值,会生成警告。

8.4. 特定的锁策略

8.4.1. 凭证

struct ucred 是内核的内部凭证结构,通常作为内核中基于进程的访问控制的基础。BSD 衍生系统使用 "写时复制" 模型来管理凭证数据:凭证结构可能会有多个引用,当需要修改时,会复制该结构,修改后再替换引用。由于凭证广泛缓存以实现打开文件时的访问控制,这带来了显著的内存节省。随着向精细粒度 SMP 的转变,这一模型也大大节省了锁操作,因为只需要在未共享的凭证上进行修改,从而避免了在使用已知共享的凭证时进行显式同步。

具有单一引用的凭证结构被认为是可变的;共享的凭证结构不得修改,否则会导致竞争条件的风险。一个互斥锁 cr_mtxp 保护 struct ucred 的引用计数,以保持一致性。在使用该结构时,必须拥有一个有效的引用,否则结构可能会在非法使用者访问时被释放。

struct ucred 的互斥锁是叶子互斥锁,并通过互斥池实现,以提高性能。

通常,凭证是以只读方式用于访问控制决策的,在这种情况下,td_ucred 更受青睐,因为它不需要加锁。当进程的凭证被更新时,必须持有 proc 锁来执行检查和更新操作,从而避免竞争条件。进程凭证 p_ucred 必须用于检查和更新操作,以防止检查时与使用时的竞争条件。

如果系统调用在更新进程凭证后执行访问控制,则必须刷新 td_ucred 的值为当前进程的值。这将防止在更改后使用过期的凭证。内核在进程进入内核时会自动刷新线程结构中的 td_ucred 指针,使其指向进程的 p_ucred,从而允许使用新鲜的凭证进行内核访问控制。

8.4.2. 文件描述符和文件描述符表

细节待补充。

8.4.3. Jail 结构

8.4.4. MAC 框架

TrustedBSD MAC 框架在多种内核对象中维护数据,形式为 struct label。一般来说,内核对象中的标签由与该内核对象其余部分相同的锁保护。例如,struct vnode 中的 v_label 标签由 vnode 锁保护。

除了在标准内核对象中维护的标签外,MAC 框架还维护一份已注册和活跃的策略列表。该策略列表由全局互斥锁(mac_policy_list_lock)和一个忙碌计数(也由互斥锁保护)来保护。由于许多访问控制检查可能会并行发生,因此对策略列表的只读访问需要在持有互斥锁的同时增减忙碌计数。互斥锁不必在整个 MAC 条目操作期间持有——某些操作,如对文件系统对象的标签操作——可能会持续较长时间。要修改策略列表(如在策略注册和注销期间),必须持有互斥锁,并且引用计数必须为零,以防止在列表正在使用时修改该列表。

一个条件变量 mac_policy_list_not_busy 可供线程等待列表变得不忙碌时使用,但只有当调用者没有持有其他锁时,才可以等待此条件变量,否则可能会出现锁顺序违反的情况。忙碌计数实际上充当了对框架访问的共享/独占锁:不同的是,与 sx 锁不同,等待列表变为不忙碌的消费者可能会被饿死,而不是允许与进入(或进入 MAC 框架内部)时持有的其他锁产生锁顺序问题。

8.4.5. 模块

对于模块子系统,存在一个单一的锁,用于保护共享数据。这个锁是共享/独占(SX)锁,并且很可能需要以共享或独占的方式获取,因此添加了一些宏来简化访问锁的过程。这些宏可以在 sys/module.h 中找到,使用起来非常基础。这个锁下保护的主要结构是 module_t 结构(当为共享时)和全局的 modulelist_t 结构,即模块。应该查看 kern/kern_module.c 中的相关源代码,以进一步理解锁定策略。

8.4.6. Newbus 设备树

8.4.7. 管道

…

8.4.8. 进程和线程

  • 进程层次结构

  • proc 锁,引用

  • 系统调用期间冻结的线程特定进程条目的副本,包括 td_ucred

  • 进程间操作

  • 进程组和会话

8.4.9. 调度器

涉及大量对 sched_lock 的引用,并且有注释指向文档中其他特定原语和相关的细节。

8.4.10. Select 和 Poll

select 和 poll 函数允许线程阻塞,等待文件描述符上的事件——通常是文件描述符是否可读或可写。

…

8.4.11. SIGIO

SIGIO 服务允许进程在指定的文件描述符的读写状态变化时,要求将 SIGIO 信号发送到其进程组。每个内核对象最多只能有一个进程或进程组注册 SIGIO,且该进程或组被称为所有者。每个支持 SIGIO 注册的对象包含一个指针字段,如果该对象没有注册,字段为 NULL,否则指向一个 struct sigio 结构,描述注册信息。这个字段由一个全局互斥锁 sigio_lock 保护。调用 SIGIO 维护函数时,必须传入该字段的“引用”,以避免在未加锁的情况下进行本地副本注册。

每个与进程或进程组关联的注册对象都分配一个 struct sigio,并包含指向对象、所有者、信号信息、凭证以及注册的一般处置的回指针。每个进程或进程组都包含一个已注册的 struct sigio 结构列表,进程为 p_sigiolst,进程组为 pg_sigiolst。这些列表分别由进程或进程组锁保护。每个 struct sigio 中的大多数字段在注册期间是常量的,唯一例外是 sio_pgsigio 字段,它将 struct sigio 链接到进程或进程组列表中。开发人员在实现新的内核对象以支持 SIGIO 时,通常希望避免在调用 SIGIO 支持函数时持有结构锁,例如 fsetown 或 funsetown,以避免定义结构锁和全局 SIGIO 锁之间的锁顺序。这通常可以通过使用结构的提高引用计数来实现,例如在管道操作期间依赖文件描述符对管道的引用。

8.4.12. Sysctl

sysctl MIB 服务通过系统调用从内核和用户空间应用程序中调用。至少有两个问题涉及锁定:首先是保护维护命名空间的结构,其次是与通过 sysctl 接口访问的内核变量和函数的交互。由于 sysctl 允许直接导出(和修改)内核统计信息和配置参数,sysctl 机制必须意识到这些变量的适当锁定语义。目前,sysctl 使用一个全局 sx 锁来序列化 sysctl 的使用;然而,它假设在 Giant 锁下运行,并且没有提供其他保护。本节的其余部分推测了 sysctl 的锁定和语义变化。

  • 需要更改 sysctl 的操作顺序,将“读取旧值,copyin 和 copyout,写入新值”改为“copyin,锁定,读取旧值并写入新值,解锁,copyout”。对于只是将旧值 copyout 并设置新值然后 copyin 的普通 sysctl,仍然可以遵循旧模型。然而,使用第二个模型处理所有 sysctl 处理程序可能更清晰,以避免锁操作。

  • 为了支持常见情况,可以在 SYSCTL_FOO 宏和结构体中嵌入一个指向互斥锁的指针。这对于大多数 sysctl 都适用。对于由 sx 锁、spin 互斥锁或其他锁定策略(除了单个睡眠互斥锁)保护的值,可以使用 SYSCTL_PROC 节点来正确进行锁定。

8.4.13. Taskqueue

Taskqueue 的接口有两个基本锁与之相关,用于保护相关的共享数据。taskqueue_queues_mutex 用作锁来保护 taskqueue_queues TAILQ。与该系统相关的另一个互斥锁是 struct taskqueue 数据结构中的锁。此同步原语的使用旨在保护 struct taskqueue 中数据的完整性。需要注意的是,由于这些锁很可能不会在 kern/subr_taskqueue.c 之外使用,因此没有单独的宏来协助用户锁定他们自己的工作。

8.5. 实现说明

8.5.1. 睡眠队列

睡眠队列是一个结构体,用于保存在等待通道上处于睡眠状态的线程列表。每个不在等待通道上睡眠的线程都会携带一个睡眠队列结构。当线程在等待通道上阻塞时,它会将其睡眠队列结构捐赠给该等待通道。与等待通道关联的睡眠队列存储在哈希表中。

睡眠队列哈希表保存那些至少有一个被阻塞线程的等待通道的睡眠队列。哈希表中的每一项称为一个睡眠队列链。该链包含一个睡眠队列的链表和一个自旋互斥锁。自旋互斥锁保护睡眠队列列表以及列表中睡眠队列结构的内容。每个等待通道只关联一个睡眠队列。如果多个线程在一个等待通道上阻塞,那么所有线程除了第一个线程外的睡眠队列将被存储在主睡眠队列的空闲睡眠队列列表中。当线程从睡眠队列中移除时,如果它不是队列中唯一的线程,则会从主队列的空闲列表中分配一个睡眠队列结构。如果是最后一个线程被恢复,它将被分配主睡眠队列。由于线程可能以不同于添加顺序的顺序从睡眠队列中移除,因此线程可能会使用不同于到达时的睡眠队列结构。

sleepq_lock 函数会锁定与特定等待通道关联的睡眠队列链的自旋互斥锁。sleepq_lookup 函数在哈希表中查找与给定等待通道关联的主睡眠队列。如果未找到主睡眠队列,它将返回 NULL。sleepq_release 函数会解锁与给定等待通道关联的自旋互斥锁。

线程通过 sleepq_add 函数添加到睡眠队列。该函数接受等待通道、指向保护等待通道的互斥锁的指针、一个等待消息描述字符串和一个标志掩码作为参数。此时应通过 sleepq_lock 锁定睡眠队列链。若没有互斥锁保护等待通道(或者是由 Giant 保护),则互斥锁指针参数应为 NULL。标志参数包含一个类型字段,指示线程将要加入的睡眠队列类型以及是否为可中断睡眠 (SLEEPQ_INTERRUPTIBLE)。当前只有两种类型的睡眠队列:通过 msleep 和 wakeup 函数管理的传统睡眠队列 (SLEEPQ_MSLEEP) 和条件变量睡眠队列 (SLEEPQ_CONDVAR)。睡眠队列类型和锁定指针参数仅用于内部断言检查。调用 sleepq_add 的代码应在锁定相关的睡眠队列链后显式解锁保护等待通道的互斥锁,然后才可以在睡眠队列上阻塞。

通过调用 sleepq_set_timeout 可以为睡眠设置超时。该函数接受等待通道和作为相对滴答计数的超时时间作为参数。如果睡眠应被到达的信号中断,则应调用 sleepq_catch_signals 函数。该函数只接受等待通道作为参数。如果此线程已经有挂起的信号,则 sleepq_catch_signals 将返回一个信号号;否则,它将返回 0。

一旦线程被添加到睡眠队列,它将使用其中一个 sleepq_wait 函数进行阻塞。根据调用者是否希望使用超时或希望睡眠被捕获信号或用户态线程调度器中断中止,存在四种等待函数。sleepq_wait 函数简单地等待,直到当前线程通过某个唤醒函数显式恢复。sleepq_timedwait 函数等待,直到线程显式恢复或之前调用 sleepq_set_timeout 设置的超时到期。sleepq_wait_sig 函数等待,直到线程显式恢复或其睡眠被中断。sleepq_timedwait_sig 函数等待,直到线程显式恢复、之前调用 sleepq_set_timeout 设置的超时到期,或者线程的睡眠被中断。所有等待函数都接受等待通道作为第一个参数。此外,sleepq_timedwait_sig 函数接受第二个布尔参数,用于指示 sleepq_catch_signals 是否发现了挂起的信号。

如果线程被显式恢复或被信号中断,等待函数将返回零,表示成功睡眠。如果线程因超时或用户态线程调度器中断恢复,则会返回相应的错误号。请注意,sleepq_wait 只能返回 0,因此它不返回任何内容,调用者应假定睡眠成功。此外,如果线程的睡眠同时超时并被中断,则 sleepq_timedwait_sig 将返回表示超时发生的错误。如果返回值为 0,且使用了 sleepq_wait_sig 或 sleepq_timedwait_sig 来阻塞,则应调用 sleepq_calc_signal_retval 来检查是否有挂起的信号,并在发现信号时计算适当的返回值。sleepq_catch_signals 的调用返回的信号号应作为唯一参数传递给 sleepq_calc_signal_retval。

通过 sleepq_broadcast 和 sleepq_signal 函数可以显式恢复处于等待通道上的线程。两个函数都接受要恢复线程的等待通道、恢复线程时提升的优先级和一个标志参数,用于指示恢复的是哪种类型的睡眠队列。优先级参数被视为最小优先级。如果恢复的线程的优先级已经高于(数值上更低)优先级参数,则不会调整其优先级。标志参数用于内部断言,确保睡眠队列不会被当作错误的类型进行处理。例如,条件变量函数不应恢复传统睡眠队列上的线程。sleepq_broadcast 函数恢复在指定等待通道上阻塞的所有线程,而 sleepq_signal 只恢复在等待通道上阻塞的优先级最高的线程。调用这些函数之前,应首先通过 sleepq_lock 锁定睡眠队列链。

通过调用 sleepq_abort 函数可以中断一个睡眠中的线程。此函数必须在持有 sched_lock 时调用,并且线程必须已排队到一个睡眠队列。线程也可以通过 sleepq_remove 函数从特定的睡眠队列中移除。该函数接受线程和等待通道作为参数,仅在线程在指定等待通道的睡眠队列中时唤醒线程。如果线程不在睡眠队列中或在不同的等待通道上的睡眠队列中,则此函数不执行任何操作。

8.5.2. 旋转门(Turnstiles)

  • 与睡眠队列的比较与对比: 旋转门和睡眠队列都是线程同步机制,但它们的作用有所不同。睡眠队列用于管理等待某些条件的线程,而旋转门主要用于管理因争用而阻塞的线程。在旋转门中,线程被放置在一个优先级队列中,以便根据优先级恢复执行。

  • 查找/等待/释放:

    • lookup:旋转门的查找操作用于查找与某个互斥锁或资源相关的旋转门。该查找通过获取锁来确保同步。

    • wait:线程在旋转门上等待时,它会被放入一个优先级队列中,直到能够获得资源为止。

    • release:一旦线程获取到资源或锁,旋转门会释放资源并将线程从等待队列中移除。

  • TDF_TSNOBLOCK竞态条件:TDF_TSNOBLOCK标志表示线程不能被阻塞。在旋转门的上下文中,竞争条件可能会发生,因为线程可能在没有有效的同步操作的情况下被错误地插入到旋转门队列中,或错过了通知从而造成死锁或优先级倒置。

  • 优先级传播: 在旋转门中,线程的优先级会传播到等待队列中。这意味着较高优先级的线程会优先获取资源,从而确保系统的响应性。

8.5.3. 互斥锁实现的细节

  • 我们是否应该要求互斥锁在 mtx_destroy() 时被拥有? 由于我们无法安全地断言互斥锁没有被其他任何人拥有,最好要求互斥锁在销毁时由某个线程持有。这样可以避免潜在的竞争条件和资源冲突。

8.5.3.1. 自旋互斥锁(Spin Mutexes)

  • 使用临界区: 自旋互斥锁通常在临界区中使用,防止多个线程同时访问共享资源。自旋锁会在等待时不断地检查锁是否可用,因此在锁被占用时,线程会保持活跃,避免进入睡眠状态。

8.5.3.2. 睡眠互斥锁(Sleep Mutexes)

  • 竞争互斥锁时的竞态条件: 当多个线程竞争相同的互斥锁时,可能会出现竞态条件,导致某些线程长时间无法获得锁或发生死锁。为了避免这种情况,需要确保互斥锁的正确使用和线程的优先级管理。

  • 为什么在持有旋转门链锁时,读取竞争互斥锁的 mtx_lock 是安全的: 读取竞争中的互斥锁状态是安全的,因为在持有旋转门链锁时,旋转门确保了资源的正确调度和同步,避免了竞态条件。

8.5.4. 见证(Witness)

  • 它的作用: 见证系统主要用于追踪锁的使用情况,确保在多线程环境中不存在锁的错误使用(如死锁或优先级倒置)。它提供了一种机制,可以在锁操作期间收集诊断信息。

  • 它如何工作: 见证通过插桩代码来监控锁的获取、释放以及锁之间的依赖关系。当系统检测到潜在的死锁或优先级倒置时,见证机制会触发警告或日志记录,从而帮助开发人员调试和优化代码。

8.6. 杂项话题

8.6.1. 中断源和 ICU 抽象

  • struct isrc:isrc 是中断源控制器(Interrupt Source Controller)结构,它用于管理和抽象硬件中断源。通过 isrc 结构,系统可以统一处理来自不同硬件组件的中断信号。

  • pic 驱动: PIC(Programmable Interrupt Controller)驱动用于管理和处理中断信号。PIC 驱动提供中断的配置、触发和优先级管理。

8.6.2. 其他随机问题/话题

  • 我们是否应该传递一个互斥锁给 sema_wait? 在某些情况下,sema_wait 可能需要一个互斥锁来确保在等待信号量时不会出现资源争用问题。传递互斥锁可以提高线程同步的可靠性。

  • 我们是否应该有不可睡眠的 sx 锁? 不可睡眠的 sx 锁通常用于不能进入睡眠状态的操作中。这类锁的设计有助于减少上下文切换,提供更高的执行效率。

  • 关于引用计数的正确使用添加一些信息: 引用计数是管理资源生命周期的有效方式,但必须小心处理,避免泄漏和无效引用。适当的增加和减少引用计数可以有效防止内存泄漏和未定义的行为。

术语表

atomic(原子操作) 如果所有操作的效果在遵循正确的访问协议时一起对其他 CPU 可见,则该操作是原子的。在最简单的情况下,原子指令由机器架构直接提供。在更高层次上,如果结构体的多个成员受到锁保护,那么一组操作如果在持有锁的情况下执行,并且在任何操作之间不释放锁,则该组操作是原子的。

另见:操作(operation)。

block(阻塞) 当线程在等待锁、资源或条件时,它处于阻塞状态。不幸的是,这个术语有些过载,因此可能会引起歧义。

另见:睡眠(sleep)。

MD 与机器相关的(Machine Dependent)。

另见:MI(与机器无关的)。

memory operation(内存操作) 内存操作是指读取和/或写入内存位置的操作。

MI 与机器无关的(Machine Independent)。

另见:MD(与机器相关的)。

operation(操作) 见 memory operation(内存操作)。

primary interrupt context(主中断上下文) 主中断上下文是指中断发生时执行的代码。该代码可以直接运行中断处理程序,或者调度一个异步中断线程来执行给定中断源的中断处理程序。

realtime kernel thread(实时内核线程) 高优先级内核线程。目前,唯一的实时优先级内核线程是中断线程。

另见:线程(thread)。

sleep(睡眠) 当线程在条件变量或通过 msleep 或 tsleep 在睡眠队列上阻塞时,线程处于睡眠状态。

另见:阻塞(block)。

sleepable lock(可睡眠锁) 可睡眠锁是一个线程可以在其上睡眠的锁。Lockmgr 锁和 sx 锁是当前 FreeBSD 中唯一的可睡眠锁。最终,一些 sx 锁,如 allproc 锁和 proctree 锁,可能会变成不可睡眠的锁。

另见:睡眠(sleep)。

thread(线程) 由 struct thread 表示的内核线程。线程拥有锁并持有单一的执行上下文。

wait channel(等待通道) 线程可以在其上睡眠的内核虚拟地址。

struct prison 存储与使用 API 创建的 jail 相关的管理信息。这包括每个 jail 的主机名、IP 地址和相关设置。该结构是引用计数的,因为对该结构实例的指针被多个凭证结构共享。一个单一的互斥锁 pr_mtx 保护对引用计数和 struct jail 内部所有可变变量的读写访问。某些变量仅在 jail 创建时设置,且拥有有效的 struct prison 引用即可读取这些值。每个条目的精确锁定方式在 sys/jail.h 中通过注释进行了记录。

Newbus 系统将使用一个 sx 锁。读操作将持有共享(读)锁(),写操作将持有独占(写)锁()。内部函数将不进行锁定。外部可见的函数会根据需要进行锁定。那些如果竞争获胜或失败都无关紧要的项将不会加锁,因为它们通常被频繁读取(例如,)。Newbus 数据结构的变化相对较少,因此一个锁应该足够且不会带来性能损失。

critical section(临界区) 不允许被抢占的代码段。通过使用 API 进入和退出临界区。

术语表
jail(2)
sx_slock(9)
sx_xlock(9)
device_get_softc(9)
critical_enter(9)