最近 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 可能出于以下两个原因:
那个第二种情况相当有趣。当一个 Ruby 线程开始运行时,Ruby 虚拟机使用另一个后台线程(在虚拟机级别),该线程休眠 10 毫秒(“滴答(Tick)”),然后检查 Ruby 线程已经运行了多长时间。如果线程运行的时间超过了量子的长度,Ruby 虚拟机就会从活跃线程中收回 GVL(“抢占”),并将 GVL 交给在 GVL 队列中等待的下一个线程。之前正在执行的线程现在会排到队列的末尾。换句话说:
就是这样!这就是 Ruby 线程争用的情况。一切都井然有序,只是可能比预期或希望的要花费更长的时间。
多线程行为中令人畏惧的“尾部延迟(Tail Latency)”可能会发生,这与“Ruby 线程量子”(Ruby Thread Quantum)有关。
比如:当你有一个时间非常短请求时,例如:
但是它相邻的运行线程是这样:
在这种情况下,CPU 密集型线程将非常贪婪地持有 GVL,它看起来会是这样:
这是在这个只有两个线程的简单场景中最坏的情况。随着更多不同工作负载的线程,你可能会遇到更多的问题。Ivo Anjo 也对此进行了讨论。你可以通过降低整体线程量子来加快速度,或者通过降低 CPU 密集型线程的优先级(降低这个线程的量子)来实现。这将导致 CPU 密集型线程被更细致地切分,但由于最小时间片由时钟周期 Tick(10 毫秒)决定,所以对于上面这个 I/O 密集型线程来说,其等待时间理论上永远不会低于 100 毫秒,这比优化前快了 10 倍。
线程的 quantum 时间是 100ms
源码位置 thread.c#L119
// .....
static uint32_t thread_default_quantum_ms = 100;
// .....
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
}