在现代 Ruby 应用里,并发一直是个让人又爱又恨的话题。爱的是 Ruby 写起来顺手,恨的是以前因为全局锁(GVL)的存在,多线程想做并行计算总是差那么一口气。后来 Ruby 3.0 带来了 Ractor,它的设计思路很不一样:不靠锁,靠消息传递。今天就用大白话聊聊怎么用 Ractor 把高吞吐任务处理得明明白白,而且尽量不踩坑。

一、先说说以前并发为什么别扭

在 Ractor 出现之前,我们写多线程程序,最常用的是 Thread。但 Ruby 的 GVL 会让同一时刻只有一个线程在执行 Ruby 代码,所以即使你开了 8 个线程,遇到 CPU 密集型的任务,依然只能用一个核心。这就好比你雇了 8 个厨师,但厨房只有一个灶眼,大家只能轮流用。

有人会说,那我用 Process 多进程啊。进程之间内存不共享,通信靠管道和消息,写起来很麻烦。而且进程开销大,不适合做细粒度的任务。所以大家一直期待 Ruby 能有一种既安全又高效的方式,把多核能力用起来。

二、Ractor 是什么玩意

Ractor 是 Ruby 3.0 引入的并发模型。它的核心思想是:多个 Ractor 各自独立运行,它们之间不共享普通对象,只通过消息传递来通信。因为不共享内存,所以自然就不需要加锁。你可以把 Ractor 理解成一个个独立的小工,各自干各自的活,需要用材料时互相传纸条。

2.1 第一个 Ractor 示例

先看一个最简单的例子,感受一下 Ractor 怎么创建、怎么传参数、怎么拿结果。

# 技术栈:Ruby 3.0+
# 创建一个 Ractor,接收一个参数 x
ractor = Ractor.new(10) do |x|
  x * 2
end

# 从 Ractor 中取出结果
result = ractor.take
puts result # 输出 20

这段代码里,Ractor.new 后面的块就是在独立的执行环境里跑的。take 会阻塞等待 Ractor 执行完并返回结果。注意,这个块里不接受外部的局部变量,只能接收 Ractor.new 传过去的参数,这是为了保证数据不共享。

2.2 多个 Ractor 并行跑

下面用 4 个 Ractor 各算一个数,看看能不能真的并行:

# 技术栈:Ruby 3.0+
ractors = 4.times.map do |i|
  Ractor.new(i) do |n|
    # 模拟耗时计算
    sleep(n * 0.1)
    n * n
  end
end

# 等待所有结果
results = ractors.map(&:take)
p results # 输出 [0, 1, 4, 9]

在支持多核的机器上,这 4 个 Ractor 会分配到不同的 CPU 核心上执行。因为每个 Ractor 之间没有共享可变状态,所以不需要考虑锁竞争,写起来很轻松。

三、无锁的秘密:消息传递

如果你用过线程,大概对 Mutex 不陌生。线程之间共享变量,得用锁保护起来,一不小心就死锁或者数据错乱。Ractor 的做法是:普通对象不能跨 Ractor 共享,你想把一个对象给另一个 Ractor,只能通过发送消息。发送时会进行复制,或者对某些特殊对象进行移动。

3.1 发送和接收消息

Ractor 之间通过 send 和 receive 来传递消息。receive 会从当前 Ractor 的邮箱里取一条消息,没有消息就等着。看这个例子:

# 技术栈:Ruby 3.0+
# 创建一个 Ractor,等待接收消息
worker = Ractor.new do
  msg = Ractor.receive
  puts "收到消息:#{msg}"
  msg.reverse
end

# 主线程发送消息
worker.send("hello")

# 接收结果
p worker.take

这里主线程通过 worker.send 把字符串 "hello" 发过去,worker 里用 Ractor.receive 取出来,处理完再返回一个结果。整个过程没有锁,因为字符串被复制了一份传给 worker,互不影响。

3.2 可以共享的特殊对象

不过,有些对象天生是只读的,比如 Integer、Symbol、true、false、nil,它们属于不可变对象,所以可以安全地跨 Ractor 共享,不需要复制。另外,你还可以用 Ractor.make_shareable 把一个对象标记为可共享,前提是这个对象内部不包含可变状态。

# 技术栈:Ruby 3.0+
config = { "timeout" => 10, "retries" => 3 }.freeze
shareable_config = Ractor.make_shareable(config)

workers = 2.times.map do
  Ractor.new(shareable_config) do |cfg|
    cfg["timeout"] # 只能读,不能改
  end
end

p workers.map(&:take) # 输出 [10, 10]

上面这个配置对象被冻结后,通过 make_shareable 变成了可共享对象,多个 Ractor 可以同时读它,不用担心被修改。

四、实战:用 Ractor 做一个高吞吐任务队列

理论讲完了,来点实际的。假设你有一个后台服务,需要处理大量的小任务,比如从消息队列里拉数据、做格式转换、写回数据库。这种场景非常适合 Ractor:主线程负责派发任务,多个 worker Ractor 并行处理。

4.1 任务队列设计

我们需要一个简单的生产者-消费者模型。主线程不断把任务扔给 worker,worker 处理完把结果发回来。为了性能,每个 worker 处理完一个任务后,应该继续去拿下一个任务,而不是任务分配完就结束。

# 技术栈:Ruby 3.0+
# 定义任务处理函数,这里用 sleep 模拟耗时操作
def process_task(task_id)
  sleep(rand * 0.05) # 模拟不同耗时
  "task_#{task_id}_done"
end

# 创建 4 个 worker Ractor
WORKER_COUNT = 4
workers = WORKER_COUNT.times.map do |worker_id|
  Ractor.new(worker_id) do |id|
    loop do
      # 从主线程接收任务
      task_id = Ractor.receive
      result = "worker_#{id}: #{task_id}_done"
      # 把结果发送给主线程(这里简化为直接发送到主线程的邮箱)
      Ractor.yield(result)
    end
  end
end

# 主线程派发 20 个任务
20.times do |i|
  # 轮流给不同的 worker 发任务
  workers[i % WORKER_COUNT].send(i)
end

# 收集 20 个结果
results = 20.times.map do
  # 任一个 worker 返回结果都可以接收
  Ractor.yield
end

puts results.first(5)
# 输出类似于:
# worker_0: 0_done
# worker_2: 2_done
# ...

这里有个问题:主线程用一个 Ractor.yield 来接收任意 worker 的结果,但 Ractor.yield 和 receive 的适配性需要注意。实际上,worker 发送结果可以用 Ractor.receive 和 Ractor.yield 两种方式。为了让主线程能持续接收结果,我们可以在主线程里使用 Ractor.select 来同时监听多个 Ractor。

4.2 更健壮的实现:使用Ractor.select

Ractor.select 可以同时等一堆 Ractor,哪个有消息就返回哪个。这样主线程就能即时收到最先完成的任务结果。

# 技术栈:Ruby 3.0+
WORKER_COUNT = 4
TASK_COUNT = 20

# 每个 worker 循环接收任务并返回结果
workers = WORKER_COUNT.times.map do |worker_id|
  Ractor.new(worker_id) do |id|
    loop do
      task_id = Ractor.receive
      # 模拟处理,加个随机耗时
      sleep(rand * 0.02)
      # 返回结果:字符串拼接
      "worker_#{id}_done_#{task_id}"
    end
  end
end

# 派发任务:把任务发送给 workers
TASK_COUNT.times do |i|
  workers[i % WORKER_COUNT].send(i)
end

# 收集结果:因为每个 worker 会处理多个任务,所以需要取 TASK_COUNT 个结果
results = []

# 存放每个 worker 已经处理的结果数量,用于分摊
TASK_COUNT.times do
  # 从所有 worker 中选一个返回消息的
  r, message = Ractor.select(*workers)
  results << message
end

puts results.sort

在这个例子里,Ractor.select 从传入的多个 Ractor 里等待任意一个返回消息。因为是同一个 worker 在循环处理,每个 worker 会按顺序返回结果,所以贪心地取 TASK_COUNT 次就能把所有任务的结果拿完。不过要注意,如果 worker 的处理速度不一致,先发出去的任务不一定先回来,但这不是问题,我们只需要结果总数对得上即可。

4.3 带超时和错误处理

生产环境经常需要处理出错的任务。我们可以让 worker 捕获异常,并把错误信息也封装成结果返回。主线程根据结果判断是成功还是失败。

# 技术栈:Ruby 3.0+
WORKER_COUNT = 2
TASK_COUNT = 5

workers = WORKER_COUNT.times.map do |worker_id|
  Ractor.new(worker_id) do |id|
    loop do
      task_id = Ractor.receive
      begin
        # 模拟一个可能出错的任务:偶数任务直接抛出异常
        raise "模拟错误" if task_id.even?
        sleep(0.01)
        { ok: true, value: "done_#{id}_#{task_id}" }
      rescue => e
        { ok: false, error: e.message }
      end
    end
  end
end

# 派发任务
TASK_COUNT.times do |i|
  workers[i % WORKER_COUNT].send(i)
end

# 接收所有结果
all_results = TASK_COUNT.times.map do
  _, res = Ractor.select(*workers)
  res
end

# 分类统计
success = all_results.count { |r| r[:ok] }
failed = all_results.count { |r| !r[:ok] }

puts "成功:#{success},失败:#{failed}"
# 输出示例:成功:2,失败:3

这里每个 Ractor 内部用 rescue 捕获异常,并把错误信息作为一个普通哈希对象返回,不会影响主线程的运行。这样一来,任务处理就变得非常健壮。

五、Ractor 的局限性,你必须知道

Ractor 虽然好,但不是银弹。用不好反而会更慢或者报一堆错。

5.1 不是所有对象都能传递

普通对象传给 Ractor 时,Ruby 会做深度复制。如果对象太大,比如一个巨大的数组,复制开销会抵消并行带来的收益。另外,有些对象包含文件描述符、操作系统资源等,它们根本无法复制,传递时会直接抛出 Ractor::IsolationError。解决方案是把这些对象用 Ractor.make_shareable 冻结,或者干脆不要让它们跨 Ractor 传递,而是通过消息传递一个标识,让 worker 自己打开资源。

5.2 全局变量和类变量会出问题

Ractor 内部默认不允许访问全局变量和类变量(除非它们是可共享的)。如果你在某个 Ractor 里尝试读一个全局变量,会得到一个报错。这是为了强制程序的隔离性。所以写 Ractor 代码时,要么把数据传参进去,要么使用可共享对象。

5.3 输出和日志需要额外处理

多个 Ractor 同时 puts 时,输出可能会交错。虽然看起来不是大问题,但如果在生产环境记录日志,最好让所有 Ractor 把日志消息发送到一个专门的 logger Ractor,由它统一去写文件,避免出现半行日志。

六、应用场景和优缺点总结

6.1 适合用 Ractor 的场景

第一,CPU 密集型任务,比如图像处理、数据压缩、复杂计算。这一类任务能真正吃满多核,收益最明显。

第二,任务彼此独立,不需要共享状态。比如爬虫抓取多个网页、批量校验数据、并发做单元测试。

第三,高吞吐管道处理。你可以把任务切成多个阶段,每个阶段放在一个 Ractor 里,形成流水线,数据在管道里流动,天然无锁。

6.2 Ractor 的优点

不需要加锁,消灭了死锁和竞态条件的坑。代码逻辑更清晰,因为数据流是显式的。在如今多核 CPU 普及的情况下,利用多核资源比线程加锁容易得多。另外,Ractor 可以跟 Thread 混用,你可以在一个 Ractor 里再开线程,只要不共享对象就行。

6.3 Ractor 的缺点

首先是学习曲线,程序员习惯了共享内存的思维,要改成消息传递需要时间。其次是复制开销,对象太大时性能反而下降。另外,Ractor 生态还不够完善,很多 gem 并不知道 Ractor 的存在,如果某个 gem 内部使用了全局变量或类变量,在 Ractor 中就很容易触发异常。最后是调试困难,并发程序本来就难调试,Ractor 之间的消息流不像线程那样直观,出问题时要靠日志和 Ractor.select 一点一点排查。

6.4 注意事项

使用 Ractor 时,请记住以下几点:

  • 尽量传小对象,避免大对象复制。可以先压缩或者只传引用编号。
  • 把不变量冻结,用 Ractor.make_shareable 共享只读数据结构,减少复制。
  • worker 数量不要超过 CPU 核心数过多,否则上下文切换也会消耗 CPU。一般和物理核心数一致或略少。
  • 任务处理中产生的临时对象不要发送回主线程,只返回必要的结果,减少复制压力。
  • 在 Ractor 内不要修改类变量和全局变量,如果必须用,可以通过 Ractor 的消息机制去更新集中存储。
  • 如果任务中使用了非线程安全的库,比如某些加密库或数据库驱动,需要确认它们是否兼容 Ractor,否则依然会有问题。

七、总结

Ractor 给 Ruby 开发者提供了一条新的并发之路:用数据隔离换安全,用消息传递换锁。它特别适合那些任务独立、CPU 密度的场景,能让你的程序真正从多核中受益。虽然它有一些限制,比如对象复制和生态兼容问题,但只要你设计好数据结构,控制好消息粒度,Ractor 完全能成为高吞吐任务处理的一把利器。从传统线程模型切换过来会有一些不适感,但一旦你习惯了这种“小工传纸条”的方式,你会发现并发编程原来可以这么舒坦。去写一个自己的 Ractor 任务队列吧,跑起来看效果,会很有成就感。