Mark24
Ruby 的“线程竞争”就是 GVL 排队
- 作者:Ben Sheldon
- 译者:Mark24
- 原文: 博客地址
最近 Jean Boussier 发布了很多精彩的帖子:
- 《应用程序形状(application shapes)》
- 《监控GVL(instrumenting the GVL (Global VM Lock))》
- 以及 《关于移除 GVL 的想法(thoughts on removing the GVL)》。
它们都是值得一读的!
长期以来,我一直误解了“线程竞争”这个词语。作为 GoodJob(👍)的作者和 Concurrent Ruby 的维护者,以及做了十多年的 Ruby 和 Rails 相关工作,这一点确实有点尴尬。但确实如此。
我已经阅读了很久关于线程竞争的内容。
- 我可能最初是在 Nate Berkopec 的 Speedshop 博客中了解到线程竞争的。
- 线程竞争问题从 Maciej Mensfeld 《关于 Thread.pass 问题(problems with Thread.pass )》的帖子开始闯入我的脑海。
- 关于 Rail “默认 puma 线程数” 的激烈讨论。
- Ivo Anjo 对 GVL 精彩的深入研究。
通过这一切,我把线程竞争看作是竞争:一场斗争,一堆线程都在互相推搡着运行,乱糟糟地踩在彼此身上,这是一个低效、令人不悦且杂乱无章的混乱局面。但实际情况根本不是这样!
相反:当你有任意数量的线程在 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 可能出于以下两个原因:
- 当线程从执行 Ruby 代码转向进行 IO 操作时,它会释放 GVL(通常情况下;如果 IO 库没有这样做,通常被认为是一个 bug)。当线程完成其 IO 操作后,线程会排到队列的末尾。
- 当线程执行时间超过线程 “量子(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,它看起来会是这样:
- IO密集线程:启动 1 毫秒网络请求并释放 GVL
- CPU密集线程:在 GVL 被取回之前,在 CPU 上执行 100 毫秒的工作
- IO密集线程:再次获取 GVL 并启动下一个 1 毫秒网络请求并释放 GVL
- CPU密集线程:在 GVL 被取回之前,在 CPU 上执行 100 毫秒的工作
- 重复……再重复……
- 现在 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) 的存在
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
}