并发指南
本指南旨在帮助理解 cruby 源代码中的并发问题,无论是通过编写 C 代码为 Ruby 做贡献,还是为 JIT 贡献。本指南不涉及原生扩展,仅关注核心语言。它将涵盖:
-
什么需要同步?
-
如何使用 VM 锁,以及在持有此锁时可以做什么和不能做什么。
-
在持有其他原生锁时可以做什么和不能做什么。
-
VM 锁和 GVL 之间的区别。
-
什么是 VM 屏障以及何时使用它。
-
一些重要锁的锁顺序。
-
Ruby 信号处理是如何工作的。
-
定时器线程及其职责。
什么需要同步?
在 Ractor 出现之前,一次只有一个 Ruby 线程可以运行。但这并不意味着你可以忽略并发问题。定时器线程是一个原生线程,它与其他 Ruby 线程交互并修改一些 VM 内部状态,因此如果这些修改可能同时被定时器线程和 Ruby 线程并行执行,就需要进行同步。
当加入 Ractor 时,情况变得更加复杂。然而,Ractor 允许你忽略不可共享对象的同步问题,因为它们不会在 Ractor 之间共享。一次只有一个 Ruby 线程可以访问该对象。对于可共享对象,它们被深度冻结,因此对象本身不会发生变异。但是,例如在 Ractor 之间读取/写入常量确实需要同步。在这种情况下,Ruby 线程需要看到一致的 VM 视图。如果更新的发布需要 2 步甚至两个单独的指令,如本例所示,则需要同步。
大多数同步是为了保护 VM 内部状态。这些内部状态包括每个 Ractor 上的线程调度器结构、全局 Ractor 调度器、Ruby 线程和 Ractor 之间的协调、全局表(用于 fstrings、编码、符号和全局变量)等。任何可以被一个 Ractor 修改,同时又可能被另一个 Ractor 读取或修改的内容,都需要适当的同步。
VM 锁
只有一个 VM 锁,它用于只能由一个 Ractor 一次进入的临界区。没有 Ractor 时,VM 锁是无用的。它不会阻止所有 Ractor 运行,因为 Ractor 可以不尝试获取此锁而运行。如果你正在更新 Ractor 之间共享的全局数据而不使用原子操作,你需要使用锁,而这是一个方便的选择。与其他锁不同,在持有 VM 锁的情况下,你可以分配 Ruby 管理的内存。当你获取 VM 锁时,在临界区内有一些可以做和不能做的事情。
你可以(只要在获取 VM 锁之前没有持有其他锁)
-
创建 Ruby 对象,调用
ruby_xmalloc等。
你不能
-
上下文切换到另一个 Ruby 线程或 Ractor。这一点很重要,因为许多事情都可能导致 Ruby 级别的上下文切换,包括
-
通过
rb_funcall等调用任何 Ruby 方法。如果你执行 Ruby 代码,可能会发生上下文切换。这同样适用于用 C 定义的 Ruby 方法,因为它们可以在 Ruby 中重新定义。调用 Ruby 方法的事物,如rb_obj_respond_to,也是不允许的。 -
调用
rb_raise。这将调用新异常对象的initialize方法。在持有 VM 锁的情况下,你调用的任何东西都不应该能够引发异常。但是,NoMemoryError是允许的。 -
调用
rb_nogvl或可能导致上下文切换的 Ruby 级别机制,如rb_mutex_lock。 -
进入任何由 Ruby 管理的阻塞操作。这将通过
rb_nogvl或等效机制切换到另一个 Ruby 线程。阻塞操作是那些会阻塞线程进展的操作,例如sleep或IO#read。
-
在内部,VM 锁是 vm->ractor.sync.lock。
你需要在一个 Ruby 线程上才能获取 VM 锁。你也不能在任何可能在 GC 扫描期间被调用的函数中获取它,因为 MMTK 在另一个线程上进行扫描,而你需要一个有效的 ec 才能获取锁。出于同样的原因(以及其他原因),你也不能从定时器线程获取它。
其他锁
所有非 VM 锁的原生锁对于临界区内的操作有一套更严格的规则。原生锁是指使用 rb_native_mutex_lock 的任何东西。一些重要的锁包括 interrupt_lock、Ractor 调度锁(保护全局调度数据结构)、线程调度锁(每个 Ractor 本地,保护每个 Ractor 的调度数据结构)和 Ractor 锁(每个 Ractor 本地,保护 Ractor 数据结构)。
当你获取这些锁之一时,
你可以
-
通过非 Ruby 分配(如原始
malloc或标准库)来分配内存。但要小心,有些函数如strdup通过宏使用 Ruby 分配! -
使用
ccan列表,因为它们不分配内存。 -
执行常规操作,如设置变量或结构字段、操作链表、信号条件变量等。
你不能
-
分配 Ruby 管理的内存。这包括创建 Ruby 对象或使用
ruby_xmalloc或st_insert。不允许这样做是因为如果该分配导致GC,那么所有其他 Ruby 线程必须尽快(在下次检查中断或获取 VM 锁时)加入 VM 屏障。这样做的目的是在GC期间没有其他 Ractor 在运行。如果一个 Ruby 线程正在等待(阻塞)同一个原生锁,它就不能加入屏障,从而导致死锁,因为屏障永远不会完成。 -
引发异常。你也无法使用
EC_JUMP_TAG,因为它会跳出临界区。 -
上下文切换。有关更多信息,请参阅“VM 锁”部分。
VM 锁与 GVL 之间的区别
VM 锁是源代码中的一个特定锁。只有一个 VM 锁。而 GVL 更多的是锁的组合。当一个 Ruby 线程即将运行或正在运行时,“获取”GVL。由于在不同的 Ractor 中,许多 Ruby 线程可以同时运行,因此存在多个 GVL(每个 SNT 一个 + 主 Ractor 一个)。它不再可以被视为“全局 VM 锁”,就像在 Ractor 出现之前那样。
VM 屏障
有时,仅获取 VM 锁是不够的,你需要保证所有 Ractor 都已停止。例如,在运行 GC 时就会发生这种情况。要创建屏障,你需要获取 VM 锁并调用 rb_vm_barrier()。在持有 VM 锁期间,没有其他 Ractor 会运行。它不常使用,因为获取屏障会显著降低 Ractor 的性能,但了解它很有用,有时它是唯一的解决方案。
锁顺序
最好不要在同一线程上一次持有超过 2 个锁。锁定多个锁可能会导致死锁,因此请谨慎操作。当一次锁定多个锁时,请遵循跨程序一致的顺序,否则可能会导致死锁。以下是一些重要锁的顺序:
-
VM 锁在 ractor_sched_lock 之前
-
thread_sched_lock 在 ractor_sched_lock 之前
-
interrupt_lock 在 timer_th.waiting_lock 之前
-
timer_th.waiting_lock 在 ractor_sched_lock 之前
这些顺序可能会发生变化,因此如果不确定,请检查源代码。在此基础上
-
在每个
ubf(解阻塞)函数期间,在某些情况下,VM 锁可以围绕它获取。例如,在 VM 关机期间会发生这种情况。有关更多详细信息,请参阅“信号处理”部分。
Ruby Interrupt 处理
当 VM 运行 Ruby 代码时,Ruby 线程会间歇性地检查 Ruby 级别的中断。这些软件中断用于 Ruby 中的各种事物,它们可以由其他 Ruby 线程或定时器线程设置。
-
Ruby 线程检查何时应放弃其时间片。当时间用完时,原生线程会切换到另一个 Ruby 线程。
-
如果存在挂起的 Ruby 级别信号处理程序,定时器线程会向主线程发送一个“陷阱”中断。
-
Ruby 线程可以通过发送中断来让其他 Ruby 线程为它们运行任务。例如,Ractor 在需要
require文件时会向主线程发送中断,以便在主线程上执行。它们会等待主线程的结果。 -
在 VM 关机期间,会向所有 Ractor 的主线程发送一个“终止”中断,以尽快停止它们。
-
在调用
Thread#raise时,调用者会向该线程发送一个中断,告知它要引发哪个异常。 -
解锁互斥锁会向下一个等待者(如果有)发送一个中断,告诉它获取锁。
-
对条件变量发出信号或广播会告诉等待者醒来。
这不是一个完整的列表。
在向 Ruby 线程发送中断时,Ruby 线程可能会被阻塞。例如,它可能正处于 TCPSocket#read 调用中间。如果是这样,接收线程的 ubf(解阻塞函数)将从发送中断的线程(Ruby 线程或定时器线程)调用。每个 Ruby 线程都有一个 ubf,在进入阻塞操作时设置,在返回后取消设置。默认情况下,此 ubf 函数会向接收线程发送 SIGVTALRM 以尝试从内核中解除阻塞它,以便它可以检查中断。还有其他 ubfs 与系统调用无关,例如在调用 Ractor#join 或 sleep 时。所有 ubfs 都在持有 interrupt_lock 的情况下调用,因此在使用 ubfs 内的锁时要考虑这一点。
请记住,ubfs 可以从定时器线程调用,因此你不能在其中假定存在 ec。ec(执行上下文)仅在 Ruby 线程上设置。
定时器 Thread
定时器线程有几个功能。它们是:
-
向已达到完整时间片的 Ruby 线程发送中断。
-
唤醒 M:N Ruby 线程(非主 Ractor 中的线程),这些线程阻塞在
IO上或在指定超时后。这会根据操作系统使用kqueue或epoll来代表线程接收IO事件。 -
如果线程仍在系统调用上阻塞,在第一次
ubf调用后继续调用SIGVTARLM信号。 -
如果全局运行队列中有 Ractor 在等待,则信号量原生线程(
SNT)正在等待一个 Ractor。 -
如果一些
SNT被阻塞(例如在IO或Ractor#join上),则创建更多SNT。