Ruby 【翻译】Ruby 的“线程竞争”就是 GVL 排队!

Mark24 · 2025年02月07日 · 最后由 Mark24 回复于 2025年02月08日 · 236 次阅读

最近 Jean Boussier 发布了很多精彩的帖子:

它们都是值得一读的!

长期以来,我一直误解了“线程竞争”这个词语。作为 GoodJob(👍)的作者和 Concurrent Ruby 的维护者,以及做了十多年的 Ruby 和 Rails 相关工作,这一点确实有点尴尬。但确实如此。

我已经阅读了很久关于线程竞争的内容。

通过这一切,我把线程竞争看作是竞争:一场斗争,一堆线程都在互相推搡着运行,乱糟糟地踩在彼此身上,这是一个低效、令人不悦且杂乱无章的混乱局面。但实际情况根本不是这样!

相反:当你有任意数量的线程在 Ruby 中时,每个线程都会有序地排队等待获取 Ruby GVL,然后它们会温和地持有 GVL,直到它们优雅地放弃它或者它被礼貌地从他们那里拿走,然后线程回到队列的末尾,在那里它们再次耐心地等待。

这是 Ruby 中“线程竞争”的含义:GVL 的有序排队。并不那么疯狂。

让我们更进一步

我是在研究 “是否应该降低 GoodJob 的线程优先级”(我确实降低了)时意识到这一点的。这个问题是在 GitHub(我的日常工作场所)进行了一些探索之后出现的。在 GitHub,我们有一个用于维护的后台线程,如果这个后台线程执行时机恰好与 Web 服务器(Unicorn)响应 Web 请求的时间重合,就会偶尔导致我们无法达到某个 Web 请求的性能目标。

Ruby 线程是操作系统线程。而操作系统线程是抢占式的,这意味着操作系统负责在活动线程之间切换 CPU 执行。但是,Ruby 控制着它的全局虚拟机锁(GVL)。Ruby 在线程执行方面扮演了重要角色,Ruby 通过选择将 GVL 交给哪个 Ruby 线程以及何时收回 GVL 来决定操作系统正在执行哪个线程。

(旁白:Ruby 3.3 引入了 M:N 线程,这解耦了 Ruby 线程与操作系统线程的映射,但在这里忽略这个细节。)

Ruby VM 内部发生的事情在《Ruby 黑客指南》中有非常好的 C 语言级别的解释。但我会尽力在这里简要解释:

当线程到达队列的顶部并获得 GVL 时,该线程将开始运行其 Ruby 代码,直到它放弃 GVL。放弃 GVL 可能出于以下两个原因:

  1. 当线程从执行 Ruby 代码转向进行 IO 操作时,它会释放 GVL(通常情况下;如果 IO 库没有这样做,通常被认为是一个 bug)。当线程完成其 IO 操作后,线程会排到队列的末尾。
  2. 当线程执行时间超过线程“量子 (quantum)”的长度时,Ruby VM 会收回 GVL,线程再次回到队列的末尾。Ruby 线程“量子”默认为 100ms(这可以通过 Thread#priority 配置,或者从 Ruby 3.4 开始直接通过环境变量配置)。

那个第二种情况相当有趣。当一个 Ruby 线程开始运行时,Ruby 虚拟机使用另一个后台线程(在虚拟机级别),该线程休眠 10 毫秒(“滴答(Tick)”),然后检查 Ruby 线程已经运行了多长时间。如果线程运行的时间超过了量子的长度,Ruby 虚拟机就会从活跃线程中收回 GVL(“抢占”),并将 GVL 交给在 GVL 队列中等待的下一个线程。之前正在执行的线程现在会排到队列的末尾。换句话说:

“线程量子 (quantum) 决定了线程通过队列的速度,且不会比滴答 (Tick) 更快。”

就是这样!这就是 Ruby 线程争用的情况。一切都井然有序,只是可能比预期或希望的要花费更长的时间。

有什么问题

多线程行为中令人畏惧的“尾部延迟(Tail Latency)”可能会发生,这与“Ruby 线程量子”(Ruby Thread Quantum)有关。

比如:当你有一个时间非常短请求时,例如:

  • 一个可能需要 10 毫秒请求,比如向 Memcached/Redis 发起十个 1 毫秒的调用以获取一些缓存值,然后返回它们(I/O 密集型线程)

但是它相邻的运行线程是这样:

  • 一个需要 1000 毫秒的请求,大部分时间都花在字符串操作上,例如一个后台线程正在处理一堆复杂的哈希和数组,并将它们序列化成一个要发送到埋点服务器的数据。或者为 Turbo Broadcasts 渲染慢速/大型/复杂的视图(CPU 密集型线程)。

在这种情况下,CPU 密集型线程将非常贪婪地持有 GVL,它看起来会是这样:

  1. IO 密集线程:启动 1 毫秒网络请求并释放 GVL
  2. CPU 密集线程:在 GVL 被取回之前,在 CPU 上执行 100 毫秒的工作
  3. IO 密集线程:再次获取 GVL 并启动下一个 1 毫秒网络请求并释放 GVL
  4. CPU 密集线程:在 GVL 被取回之前,在 CPU 上执行 100 毫秒的工作
  5. 重复……再重复……
  6. 现在 1,000 毫秒后,理论上应该只花费 10 毫秒的 I/O 密集型线程终于完成了。这非常糟糕!

这是在这个只有两个线程的简单场景中最坏的情况。随着更多不同工作负载的线程,你可能会遇到更多的问题。Ivo Anjo 也对此进行了讨论。你可以通过降低整体线程量子来加快速度,或者通过降低 CPU 密集型线程的优先级(降低这个线程的量子)来实现。这将导致 CPU 密集型线程被更细致地切分,但由于最小时间片由时钟周期 Tick(10 毫秒)决定,所以对于上面这个 I/O 密集型线程来说,其等待时间理论上永远不会低于 100 毫秒,这比优化前快了 10 倍。

译者注

1. 考证 quantum 的存在

线程的 quantum 时间是 100ms

源码位置 thread.c#L119

// .....
static uint32_t thread_default_quantum_ms = 100;
// .....

2. 考证 Tick(10ms)的存在

源码位置 thread_pthread.c#L2829

static int
timer_thread_set_timeout(rb_vm_t *vm)
{
#if 0
    return 10; // ms
#else
    int timeout = -1;

    ractor_sched_lock(vm, NULL);
    {
       // .......
            timeout = 10; // ms
       // .......
    }
    // .......
    return timeout;
#endif
}

谢谢分享,通俗易懂。不知道其它语言比如 Python 是如何实现线程锁的,是否也有这个问题?

2 楼 已删除
lixulun 回复

这里提到了 Python GIL 问题的一些细节。

https://ruby-china.org/topics/44038

Mark24 【翻译】为什么每个人都讨厌 fork(2) ? 提及了此话题。 02月08日 18:59
需要 登录 后方可回复, 如果你还没有账号请 注册新账号