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

Mark24 · 2025年02月08日 · 123 次阅读

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

Mark24 【翻译】Ruby 的“线程竞争”就是 GVL 排队! 提及了此话题。 02月08日 15:35
Mark24 【翻译】为什么每个人都讨厌 fork(2) ? 提及了此话题。 02月08日 18:59
需要 登录 后方可回复, 如果你还没有账号请 注册新账号