bdw-gc 协程支持
Crystal 使用 bdw-gc 并支持协程。这里协程被称为 Fibers。多年来,Crystal 一直是单线程的,并且使用 Fibers。单线程仍然是默认的替代方案。几年前,我们添加了多线程支持,每个线程可以并发运行多个 Fibers。这需要对 bdw-gc 进行一些修补,以及最终的贡献,以实现这一点,因为库中没有内置的协程支持。
对多线程协程的支持是通过允许用户控制每个线程的堆栈底部来实现的。更改堆栈底部和指令指针实际上赋予了协程生命。也就是说,让程序选择要执行的下一部分程序,而无需告知操作系统。告知操作系统相当于使用线程,这会更昂贵。
因此,Crystal 和其他语言可以从一个多线程程序中受益,在该程序中,每个线程可以并发执行多个协程。
在实现协程时,运行时很可能对仍然需要继续执行的现有协程进行某种形式的簿记。这些记录将包括它们的堆栈、指令指针和寄存器的持久性,以及其他特定于运行时的信息。
下面描述了 Crystal 如何在单线程和多线程模式下使用 bdw-gc 来实现协程支持。我们不会关注 Crystal 运行时及其簿记的细节,这主要关注与 bdw-gc 的接口。
两种情况下要涵盖的主题方面是
- 当当前协程需要切换到另一个协程时应该发生什么?
- 如何设置 bdw-gc 以使其了解所有协程,即使是未运行的协程,因此无法从当前堆栈访问。
单线程协程
当当前协程 C_0
需要切换到另一个协程 C_1
时,
- 我们将全局变量
GC_stackbottom
设置为stack_bottom(C_1)
- 我们在
C_0
和C_1
之间进行上下文切换
当协程被分配时,stack_bottom(C_1)
的值是已知的。分配协程通常意味着保留一些堆空间,这些空间将用作该协程的堆栈。因此,堆栈底部在此时是已知的。
边缘情况是第一个协程(属于程序主线程的协程)会发生什么。嗯,通过在程序开始时使用全局变量 GC_stackbottom
,我们可以获得第一个 Fiber 的堆栈底部。
由于我们处于单线程中,所有协程要么由运行时创建,要么是主线程被视为协程。
我们如何进行上下文切换?C_0
将对一个保存所有相关上下文的例程(这与架构相关)进行常规函数调用,然后从 C_1
的保存的上下文中恢复堆栈指针并进行返回。实际上,我们挂起 C_0
并恢复 C_1
。有关此过程的更多详细信息,请参见 src/fiber/context.cr,但这不依赖于 GC。
解决 bdw-gc 设置部分,我们使用 GC_set_push_other_roots
在 GC 尝试进行收集之前进行挂钩。在此过程中,我们将所有非当前协程的堆栈压入,即:未运行的堆栈。
GC 已经通过 GC_stackbottom
知道当前协程堆栈,所有其他协程都是通过 GC_set_push_other_roots
知道。由于 GC 将暂停主线程以执行收集,因此我们对需要关心所有内存有一个很好的了解。太棒了!
多线程协程
现在我们已经涵盖了更简单的单线程情况,我们可以讨论多线程情况。
到目前为止,我们不需要为单线程禁用 GC,而且出于性能原因,最好保持这种方式。但是对于多线程环境,我们需要在切换 Fibers 的例程周围进行一些锁定。我们使用一个全局的 读/写锁。
当当前协程 C_0
需要切换到另一个协程 C_1
时,
- 标记
C_1
将在当前线程中执行 - 在全局锁中添加一个读取器
- 我们在
C_0
和C_1
之间进行上下文切换 - 从全局锁中移除一个读取器
值得注意的是,我们没有像单线程情况那样访问 GC_stackbottom
。此外,上下文切换与之前完全相同。
由于最后一步不是上下文切换,这意味着我们需要在 Fibers 开始执行之前从全局锁中移除一个读取器。仅针对由运行时创建的 Fibers。
可以这样想,在上下文切换之后,下一步将在 C_1
中执行,而不是在 C_0
中执行。如果 C_1
之前被协程切换删除了,代码就在位,但是对于第一次执行,它将需要在程序员指示的指令之前执行最后一步。
解决 bdw-gc 设置部分,我们仍然使用 GC_set_push_other_roots
在 GC 尝试进行收集之前进行挂钩。在此过程中,我们将所有未运行的协程的堆栈压入。我们还需要处理正在运行的协程,在这种情况下,每个应用程序线程都有一个(我们称之为应用程序线程,因为还有 GC 线程我们可以省略)。
因此,作为此过程的一部分,我们还告知 GC 所有正在运行的 Fibers 的堆栈底部。为此,我们迭代所有应用程序线程并调用 GC_set_stackbottom
,并使用迭代线程的正在运行的 Fiber 的堆栈底部。
作为此过程的最后一步,我们从全局锁中移除一个写入器。
因此,总结在 GC_set_push_other_roots
中注册的过程,请执行以下操作
- 将所有未运行的 Fibers 的堆栈压入
- 通过
GC_set_stackbottom
告知每个正在运行的 Fiber 的堆栈底部(每个应用程序线程一个) - 从全局锁中移除一个写入器
与单线程类似,对于每个上下文切换,我们都需要调用 GC_set_stackbottom
,但是调用 GC_set_stackbottom
会获取 GC 锁,因此最好仅在必要时进行。单线程情况可能模仿这一点,但出于历史原因,我们最终产生了这种差异。
我们缺少写入器添加到全局锁的位置。这在 GC_set_start_callback
中的注册回调中完成。
全局锁的作用是允许同时进行上下文切换,除非正在进行收集。
与单线程情况一样,有两种协程,a) 由运行时手动创建的协程,它们在程序的堆中拥有一个堆栈,以及 b) 对应于线程初始堆栈的协程。知道第一个协程的堆栈底部与之前一样,内存是已知的。对于后者,我们在 Fibers 在运行时注册时使用 GC_get_my_stackbottom
。
因此,这就是如何使用 GC_get_my_stackbottom
和 GC_set_stackbottom
在多线程环境中启用协程!有很多部分需要协同工作才能实现这一点,因此我们希望这能澄清它们的使用方式。
源代码
有一些细节没有涵盖,但这些与 Crystal 运行时有关:如何保持 Fibers 堆栈内存的池以便重复使用、线程安全链表中的正在运行的 Fibers 和线程列表等。如果您有兴趣进一步了解这些细节,相关的文件是