第 8 章。SMPng 设计文档

8.1. 简介

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

本文档正在开发中,并将更新以反映 SMPng 项目的持续设计和实现活动。许多部分目前仅以大纲形式存在,但在工作进行中将逐步完善。有关文档的更新或建议,请联系文档编辑。

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

8.2. 基本工具和锁定基础

8.2.1. 原子指令和内存屏障

目前已经存在许多关于内存屏障和原子指令的论述,因此本节不会包含过多细节。简而言之,如果使用锁来保护对该变量的写入,则无法在没有锁的情况下读取变量。当您考虑到内存屏障仅仅确定内存操作的相对顺序时,这一点就变得显而易见了;它们对内存操作的时序没有任何保证。也就是说,内存屏障不会强制 CPU 的本地缓存或存储缓冲区中的内容刷新。相反,锁释放时的内存屏障仅仅确保,如果对释放锁的写入是可见的,则对受保护数据的所有写入都将对其他 CPU 或设备可见。CPU 可以自由地将该数据保留在其缓存或存储缓冲区中,只要它愿意。但是,如果另一个 CPU 对同一数据执行原子指令,则第一个 CPU 必须保证更新后的值与内存屏障可能要求的任何其他操作一起对第二个 CPU 可见。

例如,假设一个简单的模型,其中数据在主内存(或全局缓存)中被认为是可见的,当在一个 CPU 上触发原子指令时,其他 CPU 的存储缓冲区和缓存必须刷新对同一缓存行的所有写入,以及内存屏障后面的所有挂起操作。

这要求在使用由原子指令保护的项时格外小心。例如,在睡眠互斥锁实现中,我们必须使用 atomic_cmpset 而不是 atomic_set 来打开 MTX_CONTESTED 位。原因是我们将 mtx_lock 的值读入一个变量,然后根据该读出的值做出决定。但是,我们读出的值可能过时,或者在我们做出决定时可能发生变化。因此,当 atomic_set 执行时,它最终可能会在与我们做出决定的值不同的值上设置位。因此,我们必须使用 atomic_cmpset 仅在我们做出决定的值是最新的且有效的时设置值。

最后,原子指令只允许更新或读取一项。如果需要原子地更新多项,则必须使用锁。例如,如果必须读取两个计数器,并且它们的值彼此之间一致,则必须使用锁来保护这些计数器,而不是使用单独的原子指令。

8.2.2. 读锁与写锁

读锁不需要像写锁那样强。两种类型的锁都需要确保它们正在访问的数据不是过时的。但是,只有写访问需要独占访问。多个线程可以安全地读取一个值。使用不同类型的锁来进行读写可以通过多种方式实现。

首先,sx 锁可以通过在写入时使用排它锁,在读取时使用共享锁来以这种方式使用。这种方法非常简单直观。

第二种方法有点晦涩。您可以使用多个锁来保护一个数据。然后,要读取该数据,您只需要对其中一个锁进行读锁即可。但是,要写入数据,您需要对所有锁进行写锁。这可能会使写入变得相当昂贵,但在以各种方式访问数据时可能很有用。例如,父进程指针受 proctree_lock sx 锁和每个进程的互斥锁的保护。有时 proc 锁更容易使用,因为我们只是检查我们已经锁定的进程的父进程是谁。但是,其他地方(例如 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` 函数 piggybacked 到临界区 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 系统上特别有用,因为在 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 的值也必须刷新到当前进程值。这将防止在更改后使用过时的凭证。内核会自动在进程进入内核时从进程 p_ucred 刷新线程结构中的 td_ucred 指针,允许使用新的凭证进行内核访问控制。

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

详细信息待补充。

8.4.3. Jail 结构

struct prison 存储与使用 jail(2) API 创建的 jail 的维护相关的管理细节。这包括每个 jail 的主机名、IP 地址和相关设置。此结构是引用计数的,因为指向此结构实例的指针由许多凭证结构共享。单个互斥锁 pr_mtx 保护对引用计数和 struct jail 内所有可变变量的读写访问。一些变量仅在创建 jail 时设置,对 struct prison 的有效引用足以读取这些值。每个条目的精确锁定通过 sys/jail.h 中的注释进行记录。

8.4.4. MAC 框架

TrustedBSD MAC 框架以 struct label 的形式维护各种内核对象中的数据。一般来说,内核对象中的标签受与内核对象其余部分相同的锁保护。例如,struct vnode 中的 v_label 标签受 vnode 上的 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 设备树

newbus 系统将有一个 sx 锁。读取器将持有共享(读取)锁 (sx_slock(9)),而写入器将持有排他(写入)锁 (sx_xlock(9))。内部函数根本不会进行锁定。外部可见的函数将根据需要进行锁定。那些即使竞争条件获胜或失败也不重要的项目将不会被锁定,因为它们往往会被到处读取(例如,device_get_softc(9))。对 newbus 数据结构的更改相对较少,因此单个锁应该足够,并且不会造成性能损失。

8.4.7. 管道

…​

8.4.8. 进程和线程

  • 进程层次结构

  • proc 锁、引用

  • 线程特定版本的 proc 条目,以便在系统调用期间冻结,包括 td_ucred

  • 进程间操作

  • 进程组和会话

8.4.9. 调度程序

大量对 sched_lock 的引用,以及指向文档中其他地方的特定原语和相关魔术的注释。

8.4.10. Select 和 Poll

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

…​

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 支持函数(例如 fsetownfunsetown)时避免持有结构锁,以避免定义结构锁与全局 SIGIO 锁之间的锁顺序。这通常可以通过在结构上使用更高的引用计数来实现,例如在管道操作期间依赖于管道上的文件描述符引用。

8.4.12. Sysctl

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

  • 需要更改更新值的 sysctl 操作顺序,从读取旧值、复制输入和复制输出、写入新值更改为复制输入、锁定、读取旧值和写入新值、解锁、复制输出。仅复制输出旧值并设置复制输入的新值的正常 sysctl 仍然可以遵循旧模型。但是,为了避免锁定操作,对所有 sysctl 处理程序使用第二种模型可能更清晰。

  • 为了允许通用情况,sysctl 可以将指向互斥锁的指针嵌入到 SYSCTL_FOO 宏和结构中。这将适用于大多数 sysctl。对于由 sx 锁、自旋互斥锁或除了单个睡眠互斥锁之外的其他锁定策略保护的值,可以使用 SYSCTL_PROC 节点来确保正确的锁定。

8.4.13. 任务队列

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

8.5. 实现说明

8.5.1. 睡眠队列

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

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

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

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

通过调用 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 的调用是否找到一个挂起的信号。

如果线程被显式恢复或被信号中止,则等待函数将返回一个值为零的值,以指示睡眠成功。如果线程因超时或来自用户态线程调度的中断而恢复,则将返回一个相应的 errno 值。请注意,由于 sleepq_wait 只能返回 0,因此它不会返回任何东西,并且调用者应假设睡眠成功。此外,如果线程的睡眠超时并且同时被中止,则 sleepq_timedwait_sig 将返回一个错误,指示超时发生。如果返回的错误值为 0 并且使用了 sleepq_wait_sigsleepq_timedwait_sig 来阻塞,则应调用 sleepq_calc_signal_retval 函数来检查任何挂起的信号,并在发现任何信号时计算一个适当的返回值。之前对 sleepq_catch_signals 的调用返回的信号编号应作为 sleepq_calc_signal_retval 的唯一参数传递。

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

可以通过调用 sleepq_abort 函数中断正在睡眠的线程。此函数必须在持有 sched_lock 的情况下调用,并且线程必须在睡眠队列上排队。线程也可以通过 sleepq_remove 函数从特定的睡眠队列中移除。此函数接受线程和等待通道作为参数,并且仅在线程位于指定等待通道的睡眠队列上时唤醒线程。如果线程不在睡眠队列上,或者它在不同等待通道的睡眠队列上,则此函数什么也不做。

8.5.2. 转门

  • 与睡眠队列进行比较和对比。

  • 查找/等待/释放。 - 描述 TDF_TSNOBLOCK 竞争。

  • 优先级传播。

8.5.3. 互斥锁实现的细节

  • 我们是否应该要求互斥锁在 mtx_destroy() 时被拥有,因为否则我们无法安全地断言它们没有被其他人拥有?

8.5.3.1. 自旋互斥锁

  • 使用临界区...

8.5.3.2. 睡眠互斥锁

  • 描述竞争互斥锁的竞争情况

  • 为什么在持有转门链锁时,读取竞争互斥锁的 mtx_lock 是安全的。

8.5.4. 见证

  • 它做什么

  • 它是如何工作的

8.6. 其他主题

8.6.1. 中断源和 ICU 抽象

  • struct isrc

  • PIC 驱动程序

8.6.2. 其他随机问题/主题

  • 我们是否应该将互锁传递给 sema_wait

  • 我们是否应该有不可睡眠的 sx 锁?

  • 添加一些有关正确使用引用计数的信息。

术语表

原子

如果操作的所有效果在遵循正确的访问协议时对其他 CPU 一起可见,则该操作是原子的。在退化情况下,原子指令直接由机器架构提供。在更高级别,如果结构的多个成员受到锁的保护,则如果一组操作是在持有锁的情况下执行的,并且在任何操作之间都没有释放锁,则这组操作是原子的。

另请参阅操作。

阻塞

当线程正在等待锁、资源或条件时,它处于阻塞状态。不幸的是,这个术语有点过载了。

另请参阅睡眠。

临界区

不允许抢占的代码段。临界区使用 critical_enter(9) API 进入和退出。

MD

机器相关。

另请参阅 MI。

内存操作

内存操作读取和/或写入内存位置。

MI

与机器无关。

另请参见 MD。

操作

参见内存操作。

主中断上下文

主中断上下文是指发生中断时运行的代码。此代码可以直接运行中断处理程序,也可以调度异步中断线程来执行给定中断源的中断处理程序。

实时内核线程

高优先级内核线程。目前,唯一实时优先级内核线程是中断线程。

另请参见线程。

睡眠

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

另请参见块。

可睡眠锁

可睡眠锁是指可以被处于睡眠状态的线程持有的锁。Lockmgr 锁和 sx 锁目前是 FreeBSD 中唯一可睡眠锁。最终,一些 sx 锁(如 allproc 和 proctree 锁)可能会变成不可睡眠锁。

另请参阅睡眠。

线程

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

等待通道

线程可以在其上休眠的内核虚拟地址。


最后修改日期:2024 年 9 月 20 日,由 Fernando Apesteguía