Ruby 【翻译】所以,你想移除 GVL?

Mark24 · February 08, 2025 · 181 hits

我想写一篇关于 Pitchfork 的文章,解释它的起源、为什么它会是这个样子,以及我对其未来的看法。但在达到这一点之前,我认为我需要分享我对一些事情的思维模型,在这个例子中,是 Ruby 的 GVL。

长期以来,人们常说 Rails 应用程序主要是 I/O 密集型,因此 Ruby 的 GVL(全局解释器锁)并不是什么大问题,这也影响了 Ruby 基础设施中一些基础组件的设计,如 Puma 和 Sidekiq。正如我在之前的文章中解释的那样,我认为对于大多数 Rails 应用程序来说,这并不完全正确。不管怎样,GVL 的存在仍然要求这些线程化系统使用 fork(2) 才能充分利用服务器的所有核心:每个核心一个进程。为了避免所有这些问题,有些人呼吁简单地移除 GVL。

但这真的这么简单吗?

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 的替代品方案

但是,这并不是移除 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 我本想在这篇帖子中讨论的,但已经太长了,所以可能下次再说。

You need to Sign in before reply, if you don't have an account, please Sign up first.