关于
本文档介绍了线程信号,这是一种用于实现线程挂起和终止操作的 Zircon 内核机制。线程信号与对象信号无关。
目标受众群体是内核开发者以及有兴趣了解内核中挂起和终止操作工作原理的任何人。
暂停和终止是请求
挂起和终止是可以对线程执行的操作。这两项操作都是异步的,因为调用方必须等待操作完成。在内核中,这些操作作为 Thread 结构体上的实例方法实现:
Thread::Suspend - 请求线程暂停执行,直到通过 Thread::Resume 恢复执行。挂起用于实现调试程序。暂停后,可以在恢复线程之前读取/写入线程的寄存器状态。此操作通过 zx_task_suspend() 向用户模式公开。
Thread::Kill - 请求线程自行终止。此操作不会直接向用户模式公开。也就是说,尝试 zx_task_kill() 线程是错误的。不过,此操作会通过进程销毁(自愿和非自愿)间接公开。
请注意,这两项操作都被描述为请求。调用方请求目标暂停执行,或者在 kill 的情况下终止执行。调用方无法强制暂停或终止目标。虽然目标无法拒绝请求,但可以延迟操作,直到合适的时间和地点。这是设计中的一个关键元素。
如需了解这些操作为何是请求,请考虑强制终止或暂停线程的替代方案。如果线程在持有资源(如互斥量)时被强制终止,则在被销毁之前,它将没有机会释放资源。您可能会遇到内存泄漏、永久锁定的锁、损坏的数据结构等各种问题。
通过将终止和暂停建模为只能由目标线程执行的请求,我们为目标提供了一种方法,使其能够在停止执行之前(暂时停止执行 [暂停] 或永久停止执行 [终止])释放其资源并执行任何必要的清理。
安全点
在介绍如何发出终止和暂停请求之前,我们先来讨论一下线程终止的安全性。
有一个地方始终可以安全地挂起或终止线程的执行,即内核的“边缘”,就在从内核返回到用户模式之前。在返回用户模式之前,线程会展开其调用堆栈,执行所有 RAII 对象的析构函数。当它到达边缘并即将返回用户模式时,内核堆栈上将不会留下任何内容。线程可以在此处安全地暂停或终止其执行。
具体而言,线程可以在两个安全点暂停或终止。它们分别位于从系统调用返回到用户模式之前,以及从异常/故障/中断处理程序(简称异常处理程序)返回到用户模式之前。
请注意,异常处理程序不仅在用户模式下执行时调用,在内核模式下执行时,也可以调用它们。返回内核模式时,暂停或终止是不安全的,因为外部内核模式上下文可能仍持有资源。换句话说,只有在从用户模式上下文触发时,异常处理程序才是安全点。
发送信号
因此,我们知道终止和暂停只是请求,由目标线程决定何时以及如何满足请求。我们还知道,线程可以安全地自行暂停或终止的唯一位置是内核边缘,即即将返回用户模式的位置。线程信号如何融入这一切?
线程信号是请求暂停和终止的机制。每个 Thread 对象都有一个字段,其中包含一组已断言的信号。有一个位用于暂停 (THREAD_SIGNAL_SUSPEND),还有一个位用于终止 (THREAD_SIGNAL_KILL)。
如需请求暂停或终止线程,您可以在目标 Thread 对象上设置适当的位,然后根据目标的状态,以某种方式对其进行探测,以确保其及时到达安全点。在持有目标线程的锁时,该位通过原子 OR 设置。确切的唤醒类型取决于目标线程的状态:休眠/阻塞、暂停或运行。请注意,休眠/阻塞有两种类型:可中断和不可中断。我们将重点介绍可中断实例。如果目标被不间断地阻塞,发送方只需设置该位,信号就会在线程自行唤醒后的下一个安全点上执行。
休眠或屏蔽
如果目标线程处于休眠或阻塞状态,则根据定义,它并未运行,但它位于内核中。由于只有正在运行的线程才能检查其信号,因此我们必须唤醒或解除阻塞该线程。当线程被解除阻塞或唤醒时,系统会为其提供 zx_status_t。通常,该值为 ZX_OK 或 ZX_ERR_TIMED_OUT。不过,当像这样提前唤醒线程时,我们会使用一个特殊的 zx_status_t 值,在终止操作时使用 ZX_ERR_INTERNAL_INTR_KILLED,在暂停操作时使用 ZX_ERR_INTERNAL_INTR_RETRY。
当线程被唤醒/解除阻塞时,它会看到 zx_status_t 结果,并开始退出内核,展开其堆栈。一般来说,任何返回这两个特殊值之一的内核函数都会导致其调用方立即返回,并传播该值。
最终,当堆栈展开后,线程将位于边缘,即安全点。就在返回用户模式之前,线程会再次检查其信号,并通过调用 arch_iframe_process_pending_signals() 或 x86_syscall_process_pending_signals() 对其执行操作。这两者最终都会进入 Thread::Current::ProcessPendingSignals(),它会在挂起之前处理终止,并一直持续到没有信号为止。
关于阻塞情况,值得注意的一点是,解除线程阻塞需要锁定该线程所属的整个优先级继承链。如果发送者无法获取所有这些锁,则会退避并重试。
已暂停
与休眠/阻塞情况一样,线程必须恢复执行才能被终止。在终止的情况下,线程将通过 ZX_ERR_INTERNAL_INTR_KILLED 解除阻塞,并展开,直到返回到用户模式(在该模式下,线程会根据信号执行操作)。
正在运行
目标线程可能正在运行用户代码或内核代码。如果它正在运行用户代码,那么我们需要强制它进入内核,以便它可以在内核中检查其 Thread 结构的信号字段。如果它正在运行内核代码,那么我们必须相信它会在合理的时间范围内检查待处理信号。
发送方无法知道目标是处于内核模式还是用户模式,因此在这两种情况下,其行为相同。发送方会向目标当前运行所在的 CPU 发送处理器间中断 (IPI)。中断处理程序的部分工作是检查并选择性地处理待处理信号。
如果处理程序是在用户上下文中调用的,即 CPU 在中断时处于用户模式,则这是暂停/终止的安全点,处理程序将调用 arch_iframe_process_pending_signals()。
但是,如果处理程序是在内核上下文中调用的,则处理程序将不执行任何操作,因为它无法知道线程在中断时的状态。在此处暂停/终止不安全。相反,处理程序将返回到调用它的内核上下文,并依靠此外部上下文最终注意到信号并到达安全点。
您可能想知道,IPI 是否真的有必要。在以下两种情况下,这一点至关重要。第一种情况是,目标线程在用户模式下运行,并且不会自行进入内核。在没有其他中断流量的轻负载系统上,线程可能长时间不会进入内核,或者在无限循环的情况下永远不会进入内核。在这种情况下,我们需要 IPI 来确保目标线程及时观察和处理任何待处理的信号。第二种情况是,目标线程正在内核中执行长时间运行的操作,但未检查待处理信号。这些情况虽然罕见,但确实存在。最好的例子是通过 zx_vcpu_enter() 执行客户操作系统。中断会导致 VMEXIT 返回到主机内核,以便检查待处理信号并展开。
重点内容一览
我们来看一个示例,了解其运作方式。假设线程 A 正在挂起线程 B,而 B 正在执行 zx_port_wait()。根据操作执行的确切时间,我们可能会遇到几种不同的情况。我们将简要介绍每种情况。
场景 1:在系统调用之前暂停,在用户模式下运行
线程 A 在线程 B 开始其 zx_port_wait() 系统调用之前发出暂停。线程 B 仍处于用户模式,并且正在运行。线程 A 设置线程 B 的 THREAD_SIGNAL_SUSPEND 位,并向线程 B 的当前 CPU 发出 IPI。线程 B 的 CPU 接收中断并调用中断处理程序。在返回用户模式之前,线程 B 会检查其待处理信号。看到 THREAD_SIGNAL_SUSPEND 已设置,它会自行暂停。以下是线程 B 的调用栈示意图:
suspend_self()
interrupt_handler()
---- interrupt ----
user code
稍后,在恢复后,线程 B 将返回到用户模式,就像什么都没发生一样。
场景 2:在系统调用期间、阻塞之前暂停
线程 A 在线程 B 进入内核执行 zx_port_wait() 系统调用后发出挂起信号。线程 B 正在执行内核代码,尚未阻塞。与场景 1 一样,线程 A 发出 IPI,这会导致线程 B 检查待处理信号:
interrupt_handler()
---- interrupt ----
Semaphore::wait()
PortDispatcher::dequeue()
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
以下图表中的帧名称是 Rust 实现的帧名称。
C++ WaitQueue::BlockEtc() 等是同一代码的精简封装容器。
不过,这次中断处理程序会发现它是在内核上下文(而非用户上下文)中调用的,因此不会自行暂停。而是返回到调用它的内核上下文。线程 B 到达 zx_port_wait() 操作的核心,即在没有可用数据包时它将阻塞的点。线程 B 发现没有可用的数据包,并准备阻塞:
WaitQueue::block_etc_preamble()
Semaphore::wait()
PortDispatcher::dequeue()
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
在阻塞之前,它会检查待处理信号,并发现它已被要求挂起。如果设置了终止位,Thread::check_kill_or_suspend_signal() 会返回 ZX_ERR_INTERNAL_INTR_KILLED;如果仅设置了暂停位,则会返回 ZX_ERR_INTERNAL_INTR_RETRY。线程 B 不会阻塞,而是返回 ZX_ERR_INTERNAL_INTR_RETRY,并且调用堆栈会展开到边缘,就在返回到用户模式之前:
WaitQueue::block_etc_preamble() ZX_ERR_INTERNAL_INTR_RETRY
Semaphore::wait() |
PortDispatcher::dequeue() |
sys_port_wait() |
syscall_dispatch() V
---- syscall ----
vdso
zx_port_wait()
user code
此时,线程会检查是否有待处理的信号,并自行挂起。恢复后,线程会返回到用户模式(返回到 vDSO),并显示状态结果 ZX_ERR_INTERNAL_INTR_RETRY。vDSO 具有用于处理返回 ZX_ERR_INTERNAL_INTR_RETRY 的系统调用的特殊逻辑,它只是使用原始参数重新发出系统调用:
suspend_self() ZX_ERR_INTERNAL_INTR_RETRY
syscall_dispatch() |
---- syscall ---- | A
vdso |______|
zx_port_wait()
user code
场景 3:在内核中被阻塞时挂起
线程 A 在线程 B 进入内核并阻塞(等待端口数据包)后发出暂停。线程 A 看到线程 B 被阻塞,因此它使用值 ZX_ERR_INTERNAL_INTR_RETRY 解除线程 B 的阻塞。从此时起,其行为与场景 2 相同。调用返回到用户模式,由 vDSO 重试:
blocked ZX_ERR_INTERNAL_INTR_RETRY
WaitQueueBase::block_etc_postamble() |
Semaphore::wait() |
PortDispatcher::dequeue() |
sys_port_wait() |
syscall_dispatch() |
---- syscall ---- | A
vdso |______|
zx_port_wait()
user code
场景 4:解除阻塞后、从内核返回前暂停
当线程 B 处于阻塞状态,等待端口数据包时,一个数据包到达,从而解除其阻塞状态(使用 ZX_OK):
blocked ZX_OK
WaitQueueBase::block_etc_postamble() |
Semaphore::wait() |
PortDispatcher::dequeue() V
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
当线程 A 发出暂停指令时,线程 B 正在向用户模式展开。 线程 A 设置该位,看到线程 B 被标记为正在运行,因此它会发送 IPI。与“在系统调用之前暂停”的情况类似,中断处理程序会执行:
interrupt_handler()
---- interrupt ----
PortDispatcher::dequeue()
sys_port_wait()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
不过,这次它不会检查待处理信号,因为处理程序中断的是内核上下文,而不是用户上下文。处理程序完成,线程 B 继续展开。最终,线程 B 到达边缘,即将从系统调用返回到用户模式。在这里,它会检查待处理的信号,看到 THREAD_SIGNAL_SUSPEND 并自行暂停:
suspend_self()
syscall_dispatch()
---- syscall ----
vdso
zx_port_wait()
user code
恢复后,它将返回到用户模式,并显示解除阻塞的状态结果 (ZX_OK):
syscall_dispatch() ZX_OK
---- syscall ---- |
vdso V
zx_port_wait()
user code
回顾
要点如下:
您无法强制暂停或终止线程。您只能要求它暂停或终止自身。
线程信号是请求线程暂停或终止的机制。
线程应仅在内核中的特定点暂停或终止其执行。具体而言,只有当线程不持有任何资源(例如锁)并且即将从内核模式返回到用户模式时,线程才可以暂停或终止执行。
为了保持响应能力,长时间运行的内核操作必须定期检查待处理信号,并在设置了任何信号时返回。