Ruby Ractors 探险记

“我买了 10 核 CPU,就要把它用满!”

我今天开始了一次轻松的探索,希望通过大量的 Ractor 运行数学计算来榨干 CPU,但我发现 YJIT 非常出色。

Ruby 3.0.0 发布于 2020 年 12 月,距今已经超过 4 年,它带来了 Ractors(以及 Fiber Scheduler,那是另一个故事)。Ractor 承诺了一种在同一 Ruby 进程中运行真正并发任务的方法。Ruby 在很多方面存在共享问题,而 GVL 本质上保护了这些共享访问。使用 Ractor,你可以运行只能以非常特定的方式与其他 Ractor 共享的代码。这允许多个 Ractor 并发运行代码,就像每个 Ractor 都有多个 GVL 一样。

我发现自己有一些空闲时间,想看看 Ractor 在最新的稳定 Ruby 版本 3.4.2 中的表现。以下是我的发现。

承诺

GVL 已经死了!有了 Ractors,我们现在可以用真正的并发并行性运行 Ruby 代码了!

太棒了!算是吧!

大多数时候,Ruby 应用程序,尤其是像 Web 服务、Rails 应用等,都不是 CPU 密集型的,至少不是 Ractor 真正能帮上忙的那种。许多比我聪明的人已经写过文章,说明为什么在实践中,有更好的方法来利用你电脑里沙子中的闪电。

然而,长期以来,Ruby 一直被认为是“由于 GVL 而无法实现真正的并发”,事实上,这也是我职业生涯中的大部分经验。我只是想以 1000% 的 CPU 运行一些 Ruby 代码! 让我们开始吧!

从一些基准测试开始

这些测试都是在 Ruby 3.4.2 上运行的,这是撰写本文时最新的稳定版本。

CPU 密集型基准测试

由于 Ractor 最终应该允许我们以 最高并发度 运行纯 Ruby 代码,所以我想从这里开始。根据我的理解,以及 Ruby 3.0 发布说明1,我认为我可以做一些简单的 CPU 工作,将其放入并行 Ractor 中,并愉快地见证我的 Ruby 进程使用超过 100% 的 CPU 来完成工作。当然,这项工作应该真正并发地完成,而且我们应该看到总执行时间明显减少(与 Ractor 的数量成比例)!

模拟 CPU 密集型工作

在阅读了 Byroot 的“What’s The Deal With Ractors?”和 Abiodun Olowode 的“An Introduction to Ractors in Ruby”之后,我很快就有了一些应该能够以 1000% CPU 运行的代码。

Fibonacci 数列
# 这是一个朴素的递归 Fibonacci 实现。
# 对于大于 35 的数字来说,速度非常慢
def fibonacci(n)
 ((n == 0 || n == 1) && n) || fibonacci(n - 1) + fibonacci(n - 2)
end

Ruby

Tarai 函数

参见 Ruby 3.0.0 Release Notes1,其中也使用此代码作为 Ractor 优势的示例。

def tarai(x, y, z) =
 x <= y ? y : tarai(tarai(x-1, y, z),
           tarai(y-1, z, x),
           tarai(z-1, x, y))

Ruby

将 Ractor 与基准进行比较

主要受到 Byroot 示例的启发,我将这两个基准测试文件放在一起,可以使用简单的 ruby fibo.rbruby tarai.rb 运行。

require 'benchmark'
CONCURRENCY = 10
STARTING_PARAMS = [14, 7, 0].freeze
def tarai(x, y, z) =
 x <= y ? y : tarai(tarai(x-1, y, z),
           tarai(y-1, z, x),
           tarai(z-1, x, y))
# 串行调用 tarai n 次。
# 用作比较的基准。
def serial_tarai(n)
 n.times.map { tarai(*STARTING_PARAMS) }
end
def threaded_tarai(n)
 n.times.map do
  Thread.new { tarai(*STARTING_PARAMS) }
 end.map(&:value)
end
def ractor_tarai(n)
 n.times.map do
  Ractor.new { tarai(*STARTING_PARAMS) }
 end.map(&:take)
end
#start_benchmark
Benchmark.bm(15, ">times faster:") do |x|
 s = x.report('serial') { serial_tarai(CONCURRENCY) }
 t = x.report('threaded') { threaded_tarai(CONCURRENCY) }
 r = x.report('ractors') { ractor_tarai(CONCURRENCY) }
 [t/r]
end
#end_benchmark

Ruby

require 'benchmark'
CONCURRENCY = 10
FIB_NUM = 38
# 这是一个朴素的递归 Fibonacci 实现。
# 对于大于 35 的数字来说,速度非常慢
def fibonacci(n)
 ((n == 0 || n == 1) && n) || fibonacci(n - 1) + fibonacci(n - 2)
end
# 串行调用 fibonacci n 次。
# 用作比较的基准。
def serial_fibonacci(concurrency, n)
 concurrency.times.map do
  fibonacci(n)
 end
end
def threaded_fibonacci(concurrency, n)
 concurrency.times.map do
  Thread.new { fibonacci(n) }
 end.map(&:value)
end
def ractor_fibonacci(concurrency, n)
 concurrency.times.map do
  Ractor.new(n) { |num| fibonacci(num) }
 end.map(&:take)
end
#start_benchmark
Benchmark.bm(15, ">times faster:") do |x|
 s = x.report('serial')  { serial_fibonacci(CONCURRENCY, FIB_NUM) }
 t = x.report('threaded') { threaded_fibonacci(CONCURRENCY, FIB_NUM) }
 r = x.report('ractors') { ractor_fibonacci(CONCURRENCY, FIB_NUM) }
 [t/r]
end
#end_benchmark

Ruby

你可以在我的 ruby-ractor-benchmarks 存储库中找到这些代码和其他一些基准测试。

获取一些结果

基准测试的一致性很难保证!

在这个过程的早期,我从我的基准测试中得到了高度可变的结果。我发现了本地 Ruby 安装的问题,这些问题导致了不可靠的结果。我仍然不 100% 确信我的 Docker 案例是正确的。但是,我现在得到的结果是高度可重复的。它们在每台我尝试的机器上都产生了可靠的结果。在迭代地隔离和消除问题后,我已经达到了一个地步,我的测试用例正在重现误差范围内的结果,这对我来说已经足够好了。

一些成功

我们做到了!我们从一个 Ruby 进程中获得了超过 100% 的 CPU 使用率。

活动监视器显示单个 Ruby 进程的 CPU 使用率为 686.9%

不错。

最初的糟糕结果

然而,最初我在我的 M1 Macbook Pro 上得到了一些非常令人失望的结果。Ractor 并行地消耗 CPU,但最终结果并不比串行更快,有时甚至更糟,Ractor 的实际时间有时比串行更长。

例如,对于 4 和 8 并发,我得到以下结果:

ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]
Benchmarking 4 Iterations
tak.rb:17: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
      user   system   total    real
serial  55.316336  0.168104 55.484440 ( 55.549630)
ractors 156.600156  0.191462 156.791618 ( 39.417857)
Benchmarking 8 Iterations
       user   system   total    real
serial  112.069091  0.457459 112.526550 (113.354157)
ractors 517.257652  1.860301 519.117953 ( 87.999958)

113 秒 vs 88 秒!有些事情似乎不对劲。我期望看到多个 X 倍的加速,尤其是在运行 8 个 Ractor 时,但我在这里只看到了 1.3 倍的加速。

有时会发生奇怪的事情

在调试我的机器上非常糟糕的结果的过程中,我开始消除变量。我重新安装了 Ruby 3.4.2,并看到了更好的结果,这些结果与在线帖子和合理的预期更加一致。从那以后,我再也没有在 macOS 上遇到无法解释的糟糕结果。一定是我之前安装的 Ruby 3.4.2 在某种程度上出现了微妙的故障。它可以正常工作,但比全新安装要慢得多。

最后,我在我的 M1 上看到了不错的结果。当使用 4 个 Ractor 运行时,Ractor 的速度提高了 3.98 倍。这似乎完全正确。

           user   system   total    real
serial      39.366752  0.185297 39.552049 ( 40.075137)
threaded     39.115326  0.231115 39.346441 ( 39.840468)
ractors     58.220778  0.133225 58.354003 ( 10.010694)
>times faster:  0.671845  1.734772    NaN ( 3.979791)

令人困惑的 Docker 结果

我似乎能够重现的一个有趣的事情是,Ractor 在我的 MacBook 以及 AMD 机器上的 Docker 中 更慢且效率更低

例如,我们的 Fibonacci benchmarks/fibo_bm.rb 在 Docker 上运行,使用 ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [aarch64-linux]

           user   system   total    real
threaded     41.955226  0.009052 41.964278 ( 41.917704)
ractors     330.931238  0.020016 330.951254 ( 55.260660)
>times faster:  0.126779  0.452238    NaN ( 0.758545)

这里的 Ractor 总时间为 55 秒,而线程性能为 41 秒!更糟糕的是,我们看到 Ractor 确实使用了 大量的并发 CPU 时间

我们的 benchmarks/tarai_bm.rb 示例在 Docker 中也表现不佳

           user   system   total    real
threaded    112.883811  0.048076 112.931887 (112.804605)
ractors     1021.533082  0.047153 1021.580235 (170.324781)
>times faster:  0.110504  1.019575    NaN ( 0.662291)

我很想知道这是为什么。我首先检查了 Docker,看看我的本地 macOS 结果是否合理。这些结果支持了我的理论。

在 Docker 中,我得到了类似的结果,即 Ractor 比串行更慢,在 x86_64 CPU 上也是如此。

/usr/local/bin/ruby benchmarks/fibo_bm.rb
Initializing Fibonacci benchmark from benchmarks/fibo_bm.rb...
Ruby Information:
{RUBY_DESCRIPTION: "ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [x86_64-linux]", YJIT_enabled: false}
Running Fibonacci benchmark from benchmarks/fibo_bm.rb @ 2025-03-20 07:48:49 +0000...
           user   system   total    real
threaded     42.572746  0.015913 42.588659 ( 42.509952)
ractors     204.814097  0.000000 204.814097 ( 52.661219)
>times faster:  0.207860    Inf    NaN ( 0.807234)
Finished Fibonacci benchmark from benchmarks/fibo_bm.rb @ 2025-03-20 07:50:24 +0000.
Elapsed time: 95.17 seconds.
-------------------------

更多发现

我不知道为什么这些 Docker 结果如此糟糕,但我想了解更多。似乎值得研究为什么在这种情况下 Ractor 会变慢。也许使用像 Vernier 这样的工具可以提供更多的见解。

我仍在调查令人惊讶的 Docker 性能问题,并计划在后续文章中进一步探讨这个问题。如果你对容器化环境中的 Ractor 性能有任何见解,或者有其他 Ractor 表现 比串行更差 的情况,我很乐意听取你的想法!

向 Vernier 致敬

https://vernier.prof/ 我在 Bluesky 上关注 John Hawthorn,并从他那里听说了 Vernier。出于兴趣,我在 Vernier 中运行了好的示例,输出结果非常酷。

Verneir 显示串行、线程和 Ractor 运行

在这里你可以看到三种方法:串行、线程和 Ractor。

在串行情况下,你看到单个线程完成了所有工作;在线程中,你看到 5 个线程,但是如果你查看蓝色块,你会发现这些线程中只有一个在一次执行,还要注意总时间与串行方法基本相同。在 Ractor 方法中,你看到了两个值得注意的事情:线程中有 5 条实线蓝色线条,所有 5 个线程都在整个时间里执行,并且总时间要短得多!

Vernier 清楚地表明,我们的整个执行过程都在递归中。当你考虑它时,代码也清楚地表明了这一点,但是使用 Vernier,它开始讲述一个故事。火焰图和堆栈图只是我们自己的方法调用自身的堆栈和块。我们编写的代码在算法上没有任何困难。我们花费的所有时间都在 解释和调用非常简单的 Ruby 代码,并且是递归的。

真正的明星 – YJIT

在这次旅程中的某个地方,也许是在阅读 Byroot 的博客文章时,也许是在对基准测试的运行速度感到非常失望时,我想知道 YJIT 能在这里提供什么帮助。

YJIT 是“Yet Another Ruby JIT”,它似乎在每个版本中都变得更好。简化地说,当启用 YJIT 时,Ruby 代码会被“即时”编译为更高效的机器代码,以实现更快的执行速度。

碰巧的是,我的 Ruby 已经编译了 YJIT 支持,所以我所要做的就是使用 --yjit 运行 Ruby,我的基准测试就可以使用 YJIT 运行了。

我真的震惊了。我的 Fibonacci 输出从:

           user   system   total    real
serial      39.231936  0.283801 39.515737 ( 39.969249)
threaded     39.144448  0.211080 39.355528 ( 39.769842)
ractors     58.340113  0.209830 58.549943 ( 10.050438)
>times faster:  0.670970  1.005957    NaN ( 3.957026)
Finished Fibonacci benchmark from benchmarks/fibo_bm.rb @ 2025-03-20 01:00:20 -0700.
Elapsed time: 89.84 seconds.

变成了:

           user   system   total    real
serial      3.708983  0.023323  3.732306 ( 3.785196)
threaded     3.710992  0.025232  3.736224 ( 3.781451)
ractors      4.506470  0.019225  4.525695 ( 0.775657)
>times faster:  0.823481  1.312458    NaN ( 4.875159)
Finished Fibonacci benchmark from benchmarks/fibo_bm.rb @ 2025-03-20 01:00:28 -0700.
Elapsed time: 8.34 seconds.

哇!这太不可思议了。Ractor 有机会使用 4 倍的内核来获得 4 倍的性能,但是 YJIT 免费为我们提供了 10-13 倍 的加速!串行情况下 10 倍,Ractor 情况下 13 倍

YJIT 自 3.2 以来已经可以用于生产环境了。https://shopify.engineering/ruby-yjit-is-production-ready 但是它似乎从那时起变得更好了。

Shopify/yjit-bench 拥有比我尝试过的更全面的 YJIT 基准测试。

YJIT 的其他影响

YJIT 也显著提高了 Docker 案例的性能,但并没有完全解决它,至少在 x86 上没有。

结论

注意事项

感谢:

参考文章

脚注

  1. Ruby 3.0.0. 发布说明 是第一个提到 tarai 作为 Ractor 基准测试的,包括指向其来源、Wikipedia 文章 Tak (function) 的链接。↩︎ ↩︎