我想写一篇关于 Pitchfork 的文章,解释它的起源、为什么它会是这个样子,以及我对其未来的看法。但在达到这一点之前,我认为我需要分享我对一些事情的思维模型,在这个例子中,是 Ruby 的 GVL。
长期以来,人们常说 Rails 应用程序主要是 I/O 密集型,因此 Ruby 的 GVL(全局解释器锁)并不是什么大问题,这也影响了 Ruby 基础设施中一些基础组件的设计,如 Puma 和 Sidekiq。正如我在之前的文章中解释的那样,我认为对于大多数 Rails 应用程序来说,这并不完全正确。不管怎样,GVL 的存在仍然要求这些线程化系统使用 fork(2) 才能充分利用服务器的所有核心:每个核心一个进程。为了避免所有这些问题,有些人呼吁简单地移除 GVL。
但这真的这么简单吗?
如果你阅读有关 GVL 的帖子,你可能听说过它不是为了保护你的代码免受竞态条件的影响,而是为了保护 Ruby 虚拟机免受你的代码影响。换句话说,无论是否有 GVL,你的代码都可能受到竞态条件的影响,这是绝对正确的。
但这并不意味着 GVL 不是您应用程序中 Ruby 代码线程安全的重要组件。 让我们用一个简单的代码示例来说明:
译者注:这里表达很英语,比较绕口。中文的意思就是想表达:GVL 其实也会影响到你 Ruby 代码的线程安全。下面举例说明。
QUOTED_COLUMN_NAMES = {}
def quote_column_name(name)
QUOTED_COLUMN_NAMES[name] ||= quote(name)
end
您说这段代码是线程安全的吗?还是不是?
嗯,如果你回答“它是线程安全的”,你并不完全正确。但如果你回答“它不是线程安全的”,你也不完全正确。
实际答案是:“视情况而定”。
首先,这取决于你对线程安全的定义有多严格,然后取决于那个 quote 方法是否是幂等的,最后还取决于你使用的 Ruby 解释器的实现。
让我解释一下。
首先, ||=
是一种语法糖,它隐藏了这段代码实际工作方式的一些细节,所以让我们去掉它的语法糖:
QUOTED_COLUMN_NAMES = {}
def quote_column_name(name)
quoted = QUOTED_COLUMN_NAMES[name]
# Ruby 可以在这里切换线程
if quoted
quoted
else
QUOTED_COLUMN_NAMES[name] = quote(name)
end
end
在这个形式下,更容易看出 ||=
并不是一个单一的操作,而是多个操作。因此,即使在 MRI(即 CRuby 解释器)上,存在全局解释器锁(GVL),从技术上来说,Ruby 在计算 quoted = ...
之后,也有可能抢占一个线程,并恢复另一个线程,而这个线程可能会带着相同的参数进入同一个方法。
换句话说,即使有 GVL,此代码也受竞态条件影响。更准确地说,它受“检查 - 执行(check-then-act)”竞态条件影响。
译者注:“Check-then-act”是一种常见的操作模式,指的是先检查某个条件,然后根据检查结果执行相应操作。然而,这种模式在多线程环境下容易引发竞态条件(Race Condition),因为检查和执行之间存在时间间隔,在此期间其他线程可能改变相关状态,导致基于过时的检查结果执行操作。作者这里就想表达这个经典的情况。
如果它存在竞态条件,你可以逻辑上推断出它不是线程安全的。但在这里,情况又有所不同。如果 quote(name)
是幂等的,技术上确实存在竞态条件,但它又没有实际的负面影响。quote(name)
可能会被执行两次而不是一次,其中一个结果会被丢弃,谁会在乎呢?这就是为什么在我看来,上述代码实际上仍然是线程安全的,不管怎样。
译者注:“幂等”(Idempotent)是一个数学和计算机科学中的概念,指的是一个操作或函数在多次执行后,其效果与执行一次相同。换句话说,无论执行多少次,结果都不会改变。幂等性在很多领域都有重要的应用,尤其是在分布式系统、数据库操作和网络协议中。
我们可以通过使用几个线程来实验验证这一点:
QUOTED_COLUMN_NAMES = 20.times.to_h { |i| [i, i] }
def quote_column_name(name)
QUOTED_COLUMN_NAMES[name] ||= "`#{name.to_s.gsub('`', '``')}`".freeze
end
threads = 4.times.map do
Thread.new do
10_000.times do
if quote_column_name("foo") != "`foo`"
raise "There was a bug"
end
QUOTED_COLUMN_NAMES.delete("foo")
end
end
end
threads.each(&:join)
如果您使用 MRI 运行此脚本,它将正常运行,不会崩溃,并且 quote_column_name
将始终返回您预期的结果。
然而,如果您尝试使用 TruffleRuby 或 JRuby 运行它,它们是 Ruby 的替代实现,没有 GVL,您将得到大约 300 行错误:
$ ruby -v /tmp/quoted.rb
truffleruby 24.1.2, like ruby 3.2.4, Oracle GraalVM Native [arm64-darwin20]
java.lang.RuntimeException: Ruby Thread id=51 from /tmp/quoted.rb:20 terminated with internal error:
at org.truffleruby.core.thread.ThreadManager.printInternalError(ThreadManager.java:316)
... 20 more
Caused by: java.lang.NullPointerException
at org.truffleruby.core.hash.library.PackedHashStoreLibrary.getHashed(PackedHashStoreLibrary.java:78)
... 120 more
java.lang.RuntimeException: Ruby Thread id=52 from /tmp/quoted.rb:20 terminated with internal error:
at org.truffleruby.core.thread.ThreadManager.printInternalError(ThreadManager.java:316)
... 20 more
... etc
错误并不总是完全相同,有时似乎比其他时候更严重。但总的来说,它会在 TruffleRuby 或 JRuby 解释器内部深处崩溃,因为对同一哈希的并发访问导致它们遇到 NullPointerException
。
因此,我们可以说在 Ruby 的参考实现中这段代码是线程安全的,但在 Ruby 的所有实现中并不都是线程安全的。
该方式之所以如此,是因为在 MRI 中,线程调度器只能在执行纯 Ruby 代码时切换运行中的线程。每次调用实现于 C 的内置方法时,你都会隐式地受到 GVL 的保护。因此,所有实现于 C 的方法本质上都是“原子的”,除非它们明确释放 GVL。但一般来说,只有 IO 方法会释放它。
这就是为什么,这段从 Active Record 摘取的代码,没有使用 Hash
但使用了 Concurrent::Map
。
在 MRI 中,Concurrent::Map
几乎只是 Hash 的一个别名,但在 JRuby 和 TruffleRuby 中,它被定义为带有互斥锁的散列表。官方 Rails 不支持 TruffleRuby 或 JRuby,但在实际生产中,我们倾向于通过这种小改动来完成支持。
这就是为什么会有“移除 GVL”和“真的移除 GVL”。
简单的方法可以像 TruffleRuby 和 JRuby 那样:什么也不做,或者说是几乎什么也不做。
由于 TruffleRuby、JRuby 实现是基于 Java 虚拟机(JVM)的,而 JVM 是内存安全的,因此它们将这种情况下“失败但不会直接崩溃”的艰巨任务委托给了 JVM 运行时。鉴于 MRI 是用 C 语言实现的,而 C 语言以“不支持内存安全”而闻名,如果仅仅移除 GVL,当你的代码触发这种竞态条件时,虚拟机可能会遇到段错误(segmentation fault)或者更糟糕的情况,因此事情并没有那么简单。
Ruby 需要在每个可能发生竞态条件的对象上实现类似于 JVM 的做法,为每个对象设置某种原子计数器。每次访问对象时,你都会增加它并检查它是否设置为 1,以确保没有其他人正在使用它。
这本身是一项相当具有挑战性的任务,因为它意味着要检查 C 语言中实现的所有方法(包括 Ruby 本身以及流行的 C 扩展),以插入所有这些原子递增和递减操作。
它还需要在大多数 Ruby 对象中为那个新计数器额外占用一些空间,可能是 4 或 8 个字节,因为原子操作在小整数类型上不容易完成。除非当然有一些我不知情的巧妙技巧。
这也会导致虚拟机的速度变慢,因为所有这些原子递增和递减很可能会有明显的开销,因为原子操作意味着 CPU 必须确保所有核心同时看到这个操作,所以它实际上锁定了 CPU 缓存的那部分。我不会尝试猜测这种开销在实践中会有多少,但肯定不是免费的。
然后结果就是,很多原本是线程安全的纯 Ruby 代码,将不再具备这种特性。因此,除了 ruby-core 需要做的工作之外,Ruby 用户可能还需要在他们的代码、gem 等中调试大量线程安全问题。
因此,尽管 JRuby 和 TruffleRuby 团队努力使其与 MRI 尽可能兼容,但由于缺少 GVL 这一特性,大多数非平凡代码库在它们之上运行前可能至少需要进行一些调试。这并不一定需要大量努力,这取决于情况,但比您平均每年的 Ruby 升级要麻烦得多。
但是,这并不是移除 GVL 的唯一方法,另一种常见的设想是用无数的小锁来替换一个全局锁,每个可变对象一个锁。
关于需要完成的工作,它与之前的方法相当相似,你需要遍历所有 C 代码,并在每次接触可变对象时显式插入锁定和解锁语句。这还需要在每个对象上占用一些空间,可能比仅仅一个计数器要多一些。
采用这种方法,C 扩展可能仍需要一些工作,但纯 Ruby 代码将保持完全兼容。
如果您听说过最近半途而废的尝试移除 Python 的 GIL(相当于 Python 版本的 GVL),那么他们就是用的这种方法。那么,让我们看看他们做了哪些改动,从他们定义在 object.h 的基础对象布局开始。
它有很多仪式性代码(Ceremonial Code),所以这里有一个简化版本:
译者注:“仪式代码”(Ceremonial Code)是指在编程过程中,为了满足某些框架、语言特性或规范要求而必须编写的一些额外代码,这些代码本身对核心功能的实现并没有直接帮助,但却是必要的步骤。
/* Nothing is actually declared to be a PyObject, but every pointer to
* a Python object can be cast to a PyObject*. This is inheritance built by hand.
*/
#ifndef Py_GIL_DISABLED
struct _object {
Py_ssize_t ob_refcnt
PyTypeObject *ob_type;
};
#else
// Objects that are not owned by any thread use a thread id (tid) of zero.
// This includes both immortal objects and objects whose reference count
// fields have been merged.
#define _Py_UNOWNED_TID 0
struct _object {
// ob_tid stores the thread id (or zero). It is also used by the GC and the
// trashcan mechanism as a linked list pointer and by the GC to store the
// computed "gc_refs" refcount.
uintptr_t ob_tid;
uint16_t ob_flags;
PyMutex ob_mutex; // per-object lock
uint8_t ob_gc_bits; // gc-related state
uint32_t ob_ref_local; // local reference count
Py_ssize_t ob_ref_shared; // shared (atomic) reference count
PyTypeObject *ob_type;
};
#endif
那里有相当多的内容,让我来概括下。简单起见,我的整个解释都将假设 64 位架构。
也请注意,虽然我曾经是 Pythonista,那是在 15 年前,而现在我只是从远处观察 Python 的发展。总之,我会尽力准确描述他们正在做的事情,但完全有可能我会有些地方描述错误。
译者注:Pythonista 是指那些对 Python 编程语言非常热爱和精通的人,通常是对代码质量和编程风格有较高追求的开发者。
无论如何,当 GIL(Python 的全局解释器锁)没有被编译禁用的时候,每个 Python 对象都以 16B
开头,第一个 8B
称为 ob_refcnt
用于引用计数,正如其名,但实际上只使用 4B
作为计数器,其他 4B
用作位图来设置对象上的标志,就像在 Ruby 中一样。然后剩余的 8B
只是一个指向对象类的指针。
与比较,Ruby 的对象头称为 struct RBasic
也是 16B
。同样,它有一个指向类的指针,另一个 8B
用作存储许多不同的大位图 (big bitmap)。
然而,当在编译期间禁用 GIL 时,对象头现在是 32B
,大小加倍。它以 8B ob_tid
开头,用于线程 ID
,存储哪个线程拥有该特定对象。然后 ob_flags
被显式布局,但已缩减到 2B
,为 1B ob_mutex
腾出空间,并为一些我不太了解的 GC 状态腾出另一个 1B
。
该 4B ob_refcnt
字段仍然存在,但这次命名为 ob_ref_local
,并且还有一个 8B ob_ref_shared
,最后是对象类的指针。
仅通过对象布局的改变,你就能感受到额外的复杂性,以及内存开销。每个对象额外 16 个字节不是微不足道的。
现在,正如你可能从 refcnt
(ref count) 字段中猜到的,Python 的内存主要通过引用计数来管理。它们还有一个标记和清除收集器,但它只是为了处理循环引用。在这方面,它与 Ruby 相当不同,但看看他们为了使这个线程安全而必须做的事情仍然很有趣。
让我们看看在 refcount.h 中定义的 Py_INCREF 。在这里,它充满了针对各种架构的 ifdef,所以这里有一个简化版本,只包含当 GIL 激活时执行的代码,并移除了一些调试代码:
#define _Py_IMMORTAL_MINIMUM_REFCNT ((Py_ssize_t)(1L << 30))
static inline Py_ALWAYS_INLINE int _Py_IsImmortal(PyObject *op)
{
return op->ob_refcnt >= _Py_IMMORTAL_MINIMUM_REFCNT;
}
static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op)
{
if (_Py_IsImmortal(op)) {
return;
}
op->ob_refcnt++;
}
它非常简单,即使你不熟悉 C 语言,也应该能够读懂它。但基本上,它会检查引用计数是否设置为标记永生对象的魔法值,如果不是永生的,它就简单地执行一个常规的、非原子的、因此非常便宜的计数器递增。
关于“永生对象”(Immortal Objects)的补充说明,这是一个由 Instagram 工程师引入的非常酷的概念,我也一直想将其引入到 Ruby 中。如果你对类似“写时复制”(Copy-on-Write)和内存节省这类话题感兴趣,那么它绝对值得一读。
现在让我们看看移除 GIL 后的相同 Py_INCREF
函数:
#define _Py_IMMORTAL_REFCNT_LOCAL UINT32_MAX
# define _Py_REF_SHARED_SHIFT 2
static inline Py_ALWAYS_INLINE int _Py_IsImmortal(PyObject *op)
{
return (_Py_atomic_load_uint32_relaxed(&op->ob_ref_local) ==
_Py_IMMORTAL_REFCNT_LOCAL);
}
static inline Py_ALWAYS_INLINE int
_Py_IsOwnedByCurrentThread(PyObject *ob)
{
return ob->ob_tid == _Py_ThreadId();
}
static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op)
{
uint32_t local = _Py_atomic_load_uint32_relaxed(&op->ob_ref_local);
uint32_t new_local = local + 1;
if (new_local == 0) {
// local is equal to _Py_IMMORTAL_REFCNT_LOCAL: do nothing
return;
}
if (_Py_IsOwnedByCurrentThread(op)) {
_Py_atomic_store_uint32_relaxed(&op->ob_ref_local, new_local);
}
else {
_Py_atomic_add_ssize(&op->ob_ref_shared, (1 << _Py_REF_SHARED_SHIFT));
}
}
这是现在更加复杂。首先,需要原子地加载 ob_ref_local
,正如之前提到的,这比正常加载要昂贵,因为它需要 CPU 缓存同步。然后,我们仍然有对不朽对象的检查,没有新内容。
有趣的部分是最后的 if,因为有两种不同的情况,一种是对象由当前线程拥有,另一种则不是。因此,第一步是比较 ob_tid
和 _Py_ThreadId()
。这个函数太大,无法在这里包含,但你可以检查 object.h
中的实现,在大多数平台上,这基本上是免费的,因为线程 ID 总是存储在 CPU 寄存器中。
当对象由当前线程拥有时,Python 可以通过先进行非原子性增加后进行原子性存储来避免问题。而在相反的情况下,整个增加操作必须原子性,这要昂贵得多,因为它涉及到比较和交换操作。这意味着在发生竞态条件的情况下,CPU 将重试增加操作,直到在没有竞态条件的情况下完成。
用 Ruby 伪代码描述,它可能看起来像这样:
def atomic_compare_and_swap(was, now)
# 假设这个方法是一个 原子性 CPU 操作
if @memory == was
@memory = now
return true
else
return false
end
end
def atomic_increment(add)
loop do
value = atomic_load(@memory)
break if atomic_compare_and_swap(value + add, value)
end
end
因此,您可以看到,曾经是一个非常平凡的操作,即一个主要的 Python 热点,变成了一个明显更复杂的过程。Ruby 不使用引用计数,所以如果尝试移除 GVL,这个特定的情况不会立即翻译成 Ruby,但 Ruby 仍然有一系列非常频繁调用的类似例程,会受到类似的影响。
例如,因为 Ruby 的垃圾回收是代际和增量式的,当两个对象之间创建新的引用时,比如从 A 到 B,Ruby 可能需要标记 A 为需要重新扫描,这是通过在位图中翻转一个位来完成的。这是需要使用原子操作进行更改的一个例子。
但我们还没有谈到实际的锁定。当我第一次听说 Python 试图移除它们的 GIL 时,我本以为他们会利用现有的引用计数 API 来将锁定放入其中,但显然,他们并没有这样做。我不确定为什么,但我猜因为语义并不完全匹配。
相反,他们必须做我之前提到的事情,即检查 C 中实现的所有方法,以添加显式的加锁和解锁调用。为了说明,我们可以看看 list.clear()
方法,它是 Array#clear
的 Python 等价方法。
在移除 GIL 的努力之前,它看起来是这样的:
int
PyList_Clear(PyObject *self)
{
if (!PyList_Check(self)) {
PyErr_BadInternalCall();
return -1;
}
list_clear((PyListObject*)self);
return 0;
}
它看起来比实际要简单,因为大部分复杂性都在 list_clear
例程中,但无论如何,它相当直接。
项目开始一段时间后,Python 开发者注意到他们忘记给 list.clear 和其他几个方法添加锁,因此他们进行了修改:
int
PyList_Clear(PyObject *self)
{
if (!PyList_Check(self)) {
PyErr_BadInternalCall();
return -1;
}
Py_BEGIN_CRITICAL_SECTION(self);
list_clear((PyListObject*)self);
Py_END_CRITICAL_SECTION();
return 0;
}
不太糟糕,他们设法将其全部封装在两个宏中,当 Python 启用 GIL 时,这些宏只是空操作。
我不会解释 Py_BEGIN_CRITICAL_SECTION
中发生的一切,有些东西我无论如何也理解不了,但简而言之,它最终会进入 _PyCriticalSection_BeginMutex
,其中有一个快速路径和一个慢速路径:
static inline void
_PyCriticalSection_BeginMutex(PyCriticalSection *c, PyMutex *m)
{
if (PyMutex_LockFast(m)) {
PyThreadState *tstate = _PyThreadState_GET();
c->_cs_mutex = m;
c->_cs_prev = tstate->critical_section;
tstate->critical_section = (uintptr_t)c;
}
else {
_PyCriticalSection_BeginSlow(c, m);
}
}
快速路径所做的,是假设对象的 ob_mutex 字段设置为 0,并尝试通过原子比较和交换将其设置为 1:
//_Py_UNLOCKED is defined as 0 and _Py_LOCKED as 1 in Include/cpython/lock.h
static inline int
PyMutex_LockFast(PyMutex *m)
{
uint8_t expected = _Py_UNLOCKED;
uint8_t *lock_bits = &m->_bits;
return _Py_atomic_compare_exchange_uint8(lock_bits, &expected, _Py_LOCKED);
}
如果那样可以工作,它知道物体已被解锁,因此只需进行一点账目管理即可。
如果这种方法不起作用,那么它就会进入慢速路径,而在这里情况开始变得相当复杂。但为了快速描述一下,它首先会使用一个自旋锁(spin-lock),并且进行 40 次迭代。所以,在某种程度上,它会连续不断地执行 40 次比较和交换逻辑,寄希望于最终能够成功。如果这仍然不起作用,它就会将线程“挂起”(park),并等待一个信号来恢复运行。如果你对了解更多感兴趣,可以查看 Python/lock.c
中的_PyMutex_LockTimed
函数,并从那里跟踪代码。然而,对于我们的当前话题来说,互斥锁代码本身并没有那么有趣,因为假设大多数对象只被单个线程访问,所以快速路径才是最重要的。
但除了这条快速路径的成本之外,如何将锁定和解锁语句集成到现有代码库中也很重要。如果你忘记了一个 lock()
,可能会导致虚拟机崩溃,而如果你忘记了一个 unlock()
,可能会导致虚拟机死锁,这可以说是更糟糕的情况。
所以,让我们回到那个 list.clear()
例子:
int
PyList_Clear(PyObject *self)
{
if (!PyList_Check(self)) {
PyErr_BadInternalCall();
return -1;
}
Py_BEGIN_CRITICAL_SECTION(self);
list_clear((PyListObject*)self);
Py_END_CRITICAL_SECTION();
return 0;
}
您可能已经注意到 Python 是如何进行错误检查的。当发现一个不良的前置条件时,它通过一个 PyErr_*
函数生成一个异常,并返回 -1。这是因为 list.clear()
总是返回 None(Python 的 nil
),所以其 C 实现的返回类型只是一个 int。对于一个返回 Ruby 对象的函数,在错误条件下,它会返回一个 NULL
指针。
例如 list.__getitem__
,它是 Python 中的 Array#fetch
的等价物,定义为:
PyObject *
PyList_GetItem(PyObject *op, Py_ssize_t i)
{
if (!PyList_Check(op)) {
PyErr_BadInternalCall();
return NULL;
}
if (!valid_index(i, Py_SIZE(op))) {
_Py_DECLARE_STR(list_err, "list index out of range");
PyErr_SetObject(PyExc_IndexError, &_Py_STR(list_err));
return NULL;
}
return ((PyListObject *)op) -> ob_item[i];
}
您可以在尝试使用越界索引访问 Python 列表时看到该错误:
>>> a = []
>>> a[12]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
您可以识别相同的 IndexError
和相同的 list index out of range
消息。
所以在这两种情况下,当用 C 实现的 Python 方法需要抛出异常时,它们会构建异常对象,将其存储在某些线程局部状态中,然后返回一个特定的值以让解释器知道发生了异常。当解释器注意到函数的返回值是这些特殊值之一时,它开始回溯堆栈。从某种意义上说,Python 异常是经典 if (error) { return error }
模式的语法糖。
现在让我们看看 Ruby 的 Array#fetch
,看看你是否注意到在处理越界情况时有什么不同:
static VALUE
rb_ary_fetch(int argc, VALUE *argv, VALUE ary)
{
// snip...
long idx = NUM2LONG(pos);
if (idx < 0 || RARRAY_LEN(ary) <= idx) {
if (block_given) return rb_yield(pos);
if (argc == 1) {
rb_raise(rb_eIndexError, "index %ld outside of...", /* snip... */);
}
return ifnone;
}
return RARRAY_AREF(ary, idx);
}
你注意到在 rb_raise 之后没有明确的 return
吗?
这是因为 Ruby 异常与 Python 异常非常不同,因为它们依赖于 setjmp(3)
和 longjmp(3)
。
不深入细节,这两个函数本质上允许你为堆栈设置一个“保存点”并跳转回它。当它们被使用时,有点像非局部跳转 goto
,你直接跳转回父函数,所有中间函数都不会返回。
因此,Ruby 中的等效操作需要调用 setjmp
,并使用 EC_PUSH_TAG
宏将相关的检查点推送到执行上下文(本质上当前纤程),因此本质上每个核心方法现在都需要一个 rescue
子句,这并非免费。这是可行的,但可能比 Py_BEGIN_CRITICAL_SECTION
更昂贵。
但我们过于专注于是否能够移除 GVL,以至于我们没有停下来思考是否应该这么做。
在 Python 的情况下,据我所知,推动移除 GIL 的努力主要来自机器学习社区,很大程度上是因为高效地喂养显卡需要相当高的并行度,而 fork(2)
并不适合。
然而,根据我的理解,Python Web 社区,如 Django 用户,似乎对 fork(2)
满意,尽管 Python 在 Copy-on-Write(写时复制)方面相对于 Ruby 处于重大劣势,因为正如我们之前所看到的,它的引用计数实现意味着大多数对象不断被写入,因此 CoW 页面很快就会失效。
另一方面,Ruby 的标记 - 清除 GC 对写时复制(Copy-On-Write)非常友好,因为几乎所有 GC 跟踪数据都不是存储在对象本身中,而是在外部位图中。因此,GVL 无锁线程的主要论点之一,即减少内存使用,在 Ruby 的情况下就不那么重要了。
鉴于 Ruby(无论好坏)主要用于 Web 应用,这至少可以部分解释为什么移除 GVL 的压力不像 Python 那样强烈。同样,Node.js 和 PHP 也没有自由线程 (free threading),但据我所知,它们各自的社区对此并没有太多抱怨,除非我错过了什么。
如果 Ruby 要采用某种形式的自由线程,它可能需要在所有对象中添加某种形式的锁,并且会频繁地修改它,这可能会严重降低写时复制(Copy-on-Write)的效率。因此,这不会是一个纯粹的附加功能。
类似地,移除 Python GIL 的主要障碍之一一直是其对单线程性能的负面影响。当你处理易于并行化的算法时,即使单线程性能下降,通过使用更多的并行性,你可能仍然能够取得优势。但如果你使用 Python 的场景并行化困难,那么自由线程可能对你来说并不特别有吸引力。
历史上,Guido van Rossum 对移除 GIL 的立场是,只要它不影响单线程性能,他就欢迎这样做,这就是为什么它从未发生。现在,随着 Guido 不再是 Python 的仁慈独裁者,Python 指导委员会似乎愿意接受单线程性能的一些退步,但还不清楚这实际上会有多大。有一些数字在流传,但大多是来自合成基准测试等。我个人很想知道这种变化对 Web 应用的影响,在对此类变化发生在 Ruby 上感到热情之前。同时,需要注意的是,移除已被接受,但有一些前提条件,所以它还没有完成,他们可能在某个时候决定回头也是有可能的。
另一个需要考虑的问题是,对 Ruby 的性能影响可能比对 Python 更严重,因为需要额外开销的对象是可变对象,而与 Python 不同的是,Ruby 中的字符串也属于可变对象。想想一个普通的 Web 应用程序会执行多少次字符串操作。
另一方面,我想到的一个支持移除 GVL 的论点就是 YJIT。鉴于 YJIT 生成的本地代码及其关联的元数据仅限于进程范围,不再依赖 fork(2)
进行并行处理,仅通过共享所有这些内存,就能节省相当多的内存。然而,移除 GVL 也会让 YJIT 的工作变得更加困难,因此这也可能阻碍其进展。
另一个支持自由线程的论点是,派生的进程难以共享连接。因此,当您开始将 Rails 应用程序扩展到大量 CPU 核心时,您将比具有自由线程的堆栈拥有更多连接到您的数据存储,这可能会成为一个大瓶颈,尤其是在一些像 PostgreSQL 这样的具有昂贵连接的数据库中。目前,这主要通过使用外部连接池器来解决,如 PgBouncer 或 ProxySQL,我知道它们并不完美。这又是一个可能出错的新组件,但我认为这比自由线程要少很多麻烦。
最后,我想指出,GVL 并不是全部。如果目标是替换 fork(2) 为多线程,即使移除了 GVL,我们可能仍然不完全达到目标,因为 Ruby 的 GC 是“暂停世界(stop the world)”,所以随着单个进程中代码执行量的增加,因此分配也会更多,我们可能会发现它将成为新的竞争点。所以,我个人更愿意在希望移除 GVL 之前,先实现一个完全并发的 GC。
译者注:暂停世界(stop the world) :因为 GC(垃圾回收)的时候会暂停所有程序的执行,进行对游离变量的盘点、回收,再恢复执行。所以使用 GC 语言可能会很慢、甚至无法预测的卡住。高性能的游戏领域会用 C、C++ 这种手动控制内存回收的语言,避免这种特点。
在这个时候,有些人可能觉得我好像在试图洗脑人们,让他们认为 GVL 永远不会成为问题,但那并不是我的真实想法。
我绝对认为 GVL 目前在实际应用中造成了一些非常真实的问题,即竞争。但这与想要移除 GVL 是截然不同的,我相信情况可以通过其他方式显著改善。
如果您已经阅读了我关于如何在 Ruby 中正确测量 IO 时间的短文,您可能已经熟悉了 GVL 竞争问题,但让我在这里包含相同的测试脚本:
require "bundler/inline"
gemfile do
gem "bigdecimal" # for trilogy
gem "trilogy"
gem "gvltools"
end
GVLTools::LocalTimer.enable
def measure_time
realtime_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
gvl_time_start = GVLTools::LocalTimer.monotonic_time
yield
realtime = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - realtime_start
gvl_time = GVLTools::LocalTimer.monotonic_time - gvl_time_start
gvl_time_ms = gvl_time / 1_000_000.0
io_time = realtime - gvl_time_ms
puts "io: #{io_time.round(1)}ms, gvl_wait: #{gvl_time_ms.round(2)}ms"
end
trilogy = Trilogy.new
# Measure a first time with just the main thread
measure_time do
trilogy.query("SELECT 1")
end
def fibonacci( n )
return n if ( 0..1 ).include? n
( fibonacci( n - 1 ) + fibonacci( n - 2 ) )
end
# Spawn 5 CPU-heavy threads
threads = 5.times.map do
Thread.new do
loop do
fibonacci(25)
end
end
end
# Measure again with the background threads
measure_time do
trilogy.query("SELECT 1")
end
如果您运行它,您应该会得到类似的结果:
realtime: 0.22ms, gvl_wait: 0.0ms, io: 0.2ms
realtime: 549.29ms, gvl_wait: 549.22ms, io: 0.1ms
本脚本演示了 GVL 竞争如何对应用程序的延迟造成破坏。即使您使用像 Unicorn 或 Pitchfork 这样的单线程服务器,这也并不意味着应用程序只使用单个线程。拥有各种后台线程来执行一些服务任务,如监控,是非常常见的。其中一个例子是 statsd-instrument
gem。当您发出一个指标时,它会在内存中收集,然后一个后台线程负责批量序列化和发送这些指标。它应该主要是 IO 工作,因此不应该对主线程有太大影响,但在实践中,可能会发生这些类型的后台线程比您希望的更长时间地持有 GVL。
所以,尽管我的演示脚本非常极端,你绝对可以在生产环境中体验到一定程度的 GVL 竞争,无论你使用什么服务器。
但我认为尝试移除 GVL 并不一定是解决这个问题的最佳方法,因为这需要多年的泪水和汗水,才能获得点好处。
在 2006 年之前,多核 CPU 基本上不存在,然而,你仍然能够以相对顺畅的方式在电脑上多任务处理,比如在 Excel 中处理数字的同时在 Winamp 中播放音乐,而且这一切都不需要并行处理。
那是因为即使是 Windows 95 也有一个相当不错的线程调度器,但 Ruby 还没有。当 Ruby 中的线程准备好执行并需要等待 GVL 时,它会将其放入一个 FIFO 队列中,每当正在运行的线程释放 GVL,无论是由于进行了某些 I/O 操作还是因为运行了分配的 100 毫秒后,Ruby 的线程调度器就会弹出下一个线程。
没有任何优先级的概念。一个半不错的调度器应该能够注意到一个线程主要是 IO,打断当前线程来更快地调度 IO 密集型线程可能是值得的。
在尝试移除 GVL 之前,尝试实现一个合适的线程调度器是值得的。这个想法归功于 John Hawthorn。
与此同时,Aaron Patterson(tenderlove) 在 Ruby 3.4 中发布了一个更改,允许通过环境变量减少 100 毫秒的量子。这并不能解决所有问题,但可能已经在某些情况下有所帮助,所以这是一个开始。
译者注:量子(quantum)是 Ruby 解释器中的一个超时时间,默认 100 毫秒,Ruby 3.4 可以被轻松设置。解释器在执行线程的时候,如果超过了这个时间,就会回收 GVL,切换另一个线程执行。主要用来调度多个线程工作使用。当降低这个时间,可以更精细的切分正在执行的函数,加快多个线程排队轮转执行的速度,可以提高 IO 密集型应用的性能。
另一个约翰在我们的一次对话中分享的想法是,允许在 GVL 释放时进行更多的 CPU 操作。目前,大多数数据库客户端只在 IO 时真正释放 GVL,把它想象成这样:
def query(sql)
response = nil
request = build_network_packet(sql)
release_gvl do
socket.write(request)
response = socket.read
end
parse_db_response(response)
end
对于返回大量数据的简单查询,很可能你在持有 GVL(全局解释器锁)的情况下构建 Ruby 对象所花费的时间,比在释放 GVL 的情况下等待数据库响应的时间要多得多。
这是因为非常非常少的 Ruby C API 可以使用 GVL 释放,特别是任何分配对象或可能抛出异常的内容都必须获取 GVL。
如果取消这一限制,使得你可以在释放 GVL 的情况下创建基本的 Ruby 对象(如字符串、数组和哈希表),那么很可能会让 GVL 释放的时间更长,并显著减少线程竞争。
我本人并不真正支持取消 GVL,我认为这种权衡并不值得,至少目前还不值得,我也不认为它将像一些人想象的那样成为一个巨大的变革。
如果它对经典(主要是单线程)性能没有影响,我可能不会介意,但它几乎肯定会显著降低单线程性能,因此这感觉有点像“多得不如现得”的论点。
译者注:a bird in the hand is worth two in the bush(一鸟在手胜过双鸟在林)。这里翻译为:多得不如现得。到手才是真的,落袋为安的意思。
相反,我认为我们可以对 Ruby 进行一些更容易和更小的改动,这将能在更短的时间内以及更少的努力下改善情况,既对 Ruby 核心也对 Ruby 用户来说都是如此。
当然,这只是单一 Ruby 用户的观点,主要考虑的是我自己的使用场景,最终决定权在 Matz 手中,根据他认为社区想要和需要什么来决定。
目前,Matz 不想移除 GVL,而是接受了 Ractor 的提议。也许他的观点有一天会改变,我们拭目以待。
Ractor 我本想在这篇帖子中讨论的,但已经太长了,所以可能下次再说。