banner
Hyacine🦄

Hyacine🦄

【翻译】揭秘 Ruby ♦️ (1/3):一切都关于线程

Ruby 是真正的并行语言吗?!#

原文发布于 2024 年 10 月 20 日
作者:Wilfried
翻译于 2025 年 6 月 15 日
译者:Hyacine🦄 with agents : )
原文地址:https://blog.papey.fr/post/07-demystifying-ruby-01/

Ruby 是一种动态的、解释型的开源编程语言,以其简洁性、高效性和 “人类可读” 的语法而闻名。Ruby 经常用于 Web 开发,特别是与 Ruby on Rails 框架结合使用。它支持面向对象、函数式和命令式编程范式。

最知名和使用最广泛的 Ruby 虚拟机是 Matz Ruby Interpreter(又名 CRuby),由 Ruby 的创造者 Yukihiro Matsumoto(又名 Matz)开发。所有其他 Ruby 实现,如 JRubyTruffleRuby 等,都不在本博客文章的讨论范围内。

MRI 实现了全局解释器锁(Global Interpreter Lock),这是一种确保同一时间只有一个线程运行的机制,有效地限制了真正的并行性。言外之意,我们可以理解 Ruby 是多线程的,但并行性限制为 1(或者可能更多 👀)。

许多流行的 Gem,如 PumaSidekiqRailsSentry 都是多线程的。

Process 💎、Ractor 🦖、Threads 🧵 和 Fibers 🌽#

这里概述了 Ruby 中处理并发和并行的所有复杂层次(是的,它们不是同一回事)。让我们深入探讨每一个。

Mermaid Loading...

你在一个模拟的模拟中... 在另一个巨大的模拟中!* 笑得更厉害 *

默认情况下,所有这些嵌套结构都存在于你能想到的最简单的 Ruby 程序中。

我需要你相信我,所以这里有一个证明:

#!/usr/bin/env ruby

# 打印当前进程 ID
puts "Current Process ID: #{Process.pid}"

# Ractor
puts "Current Ractor: #{Ractor.current}"

# 打印当前线程
puts "Current Thread: #{Thread.current}"

# 打印当前 Fiber
puts "Current Fiber: #{Fiber.current}"
Current Process ID: 6608
Current Ractor: #<Ractor:#1 running>
Current Thread: #<Thread:0x00000001010db270 run>
Current Fiber: #<Fiber:0x00000001012f3ee0 (resumed)>

每一段 Ruby 代码都运行在一个 Fiber 中,该 Fiber 运行在一个 Thread 中,该 Thread 运行在一个 Ractor 中,该 Ractor 运行在一个 Process 中。

Process 💎#

这个可能是最容易理解的。你的计算机正在并行运行许多进程,例如:你正在使用的窗口管理器和 Web 浏览器是两个并行运行的进程。

所以要并行运行 Ruby 进程,你可以打开两个终端窗口,在每个窗口中运行一个程序,就这样(或者你也可以在程序中运行 fork)。

在这种情况下,调度由操作系统协调,进程 A 和进程 B 之间的内存是隔离的(比如,你不希望 Word 能够访问你的浏览器内存吧?)

如果你想从进程 A 向进程 B 传递数据,你需要进程间通信机制,如管道、队列、套接字、信号或更简单的东西,比如一个共享文件,其中一个读取,另一个写入(那时要小心竞态条件!)

Ractor 🦖#

Ractor 是一个新的实验性功能,旨在在 Ruby 程序内实现并行执行。由 VM(而不是操作系统)管理,Ractor 在底层使用原生线程来并行运行。每个 Ractor 表现得像同一个 Ruby 进程内的独立虚拟机(VM),拥有自己的隔离内存。"Ractor" 代表 "Ruby Actors",就像在 Actor 模型中一样,Ractor 通过传递消息来交换数据进行通信,而不需要共享内存,避免了 Mutex 方法。每个 Ractor 都有自己的 GIL,允许它们独立运行而不受其他 Ractor 的干扰。

总之,Ractor 提供了一个真正并行的模型,其中内存隔离防止了竞态条件,消息传递为 Ractor 交互提供了结构化和安全的方式,在 Ruby 中实现了高效的并行执行。

让我们试试看![1]

require 'time'

# 这里使用的 `sleep` 实际上不是真正的 CPU 密集型任务,但用来简化示例
def cpu_bound_task()
  sleep(2)
end

# 将大范围分成小块
ranges = [
  (1..25_000),
  (25_001..50_000),
  (50_001..75_000),
  (75_001..100_000)
]

# 开始计时
start_time = Time.now

# 创建 Ractor 来并行计算带延迟的总和
ractors = ranges.map do |range|
  Ractor.new(range) do |r|
    cpu_bound_task()
    r.sum
  end
end

# 从所有 Ractor 收集结果
sum = ractors.sum(&:take)

# 结束计时
end_time = Time.now

# 计算并显示总执行时间
execution_time = end_time - start_time
puts "Total sum: #{sum}"
puts "Parallel Execution time: #{execution_time} seconds"

# 开始计时
start_time = Time.now

sum = ranges.sum do |range|
  cpu_bound_task()
  range.sum
end

# 结束计时
end_time = Time.now

# 计算并显示总执行时间
execution_time = end_time - start_time

puts "Total sum: #{sum}"
puts "Sequential Execution time: #{execution_time} seconds"
warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
Total sum: 5000050000
Parallel Execution time: 2.005622 seconds
Total sum: 5000050000
Sequential Execution time: 8.016461 seconds

这就是 Ractor 并行运行的证明。

正如我之前说的,它们相当实验性,在许多 Gem 或你可能看到的代码中都没有使用。

它们真正的作用是将密集的 CPU 密集型任务分配到你所有的 CPU 核心上。

Thread 🧵#

操作系统线程和 Ruby 线程之间的关键区别在于它们如何处理并发和资源管理。操作系统线程由操作系统管理,允许它们在多个 CPU 核心上并行运行,使它们更加资源密集但能够实现真正的并行性。相比之下,Ruby 线程 —— 特别是在 MRI Ruby 中 —— 由解释器管理并受到全局解释器锁(GIL)的限制,这意味着同一时间只能有一个线程执行 Ruby 代码,将它们限制为并发而非真正的并行性。这使得 Ruby 线程轻量级(也称为 “绿色线程”),但无法充分利用多核系统(与允许多个 "Ruby VM" 在同一进程中运行的 Ractor 相对)。

让我们看看这个使用线程的代码片段:

require 'time'

def slow(name, duration)
  puts "#{name} start - #{Time.now.strftime('%H:%M:%S')}"
  sleep(duration)
  puts "#{name} end - #{Time.now.strftime('%H:%M:%S')}"
end

puts 'no threads'
start_time = Time.now
slow('1', 3) 
slow('2', 3) 
puts "total : #{Time.now - start_time}s\n\n"

puts 'threads'
start_time = Time.now
thread1 = Thread.new { slow('1', 3) }
thread2 = Thread.new { slow('2', 3) }
thread1.join
thread2.join
puts "total : #{Time.now - start_time}s\n\n"
no threads
1 start - 08:23:20
1 end - 08:23:23
2 start - 08:23:23
2 end - 08:23:26
total : 6.006063s

threads
1 start - 08:23:26
2 start - 08:23:26
1 end - 08:23:29
2 end - 08:23:29
total : 3.006418s

Ruby 解释器控制线程何时切换,通常在设定数量的指令之后或当线程执行阻塞操作(如文件 I/O 或网络访问)时。这使得 Ruby 对 I/O 密集型任务有效,即使 CPU 密集型任务仍然受到 GIL 的限制。

有一些技巧可供你使用,比如 priority 属性指示解释器你希望它优先运行具有更高优先级的线程,但不保证 Ruby VM 会遵守它。如果你想更粗暴一些,Thread.pass 是可用的。根据经验,在你的代码中使用这些低级指令被认为是一个坏主意。

但为什么首先需要 GIL 呢?因为 MRI 的内部结构不是线程安全的!这是 MRI 特有的,其他 Ruby 实现如 JRuby 没有这些限制。

最后,不要忘记线程共享内存,所以这为竞态条件打开了大门。这里有一个过于复杂的例子让你理解这一点。它依赖于类级变量共享相同内存空间的事实。将类变量用于常量以外的任何用途被认为是不好的做法。

# frozen_string_literal: true

class Counter
  # 共享类变量
  @@count = 0

  def self.increment
    1000.times do
      current_value = @@count
      sleep(0.0001)  # 小延迟以允许上下文切换
      @@count = current_value + 1  # 增加计数
    end
  end

  def self.count
    @@count
  end
end

# 创建一个数组来保存线程
threads = []

# 创建 10 个线程,都增加 @@count 变量
10.times do
  threads << Thread.new do
    Counter.increment
  end
end

# 等待所有线程完成
threads.each(&:join)

# 显示 @@count 的最终值
puts "Final count: #{Counter.count}"

# 检查最终计数是否匹配预期值
if Counter.count == 10_000
  puts "Final count is correct: #{Counter.count}"
else
  puts "Race condition detected: expected 10000, got #{Counter.count}"
end
Final count: 1000
Race condition detected: expected 10000, got 1000

这里的 sleep 强制上下文切换到另一个线程,因为它是一个 I/O 操作。这导致当上下文从一个线程切换回另一个线程时,@@count 值被重置为之前的值。

在你的日常代码中,你不应该使用线程,但知道它们存在于我们日常使用的大多数 Gem 的底层是很好的!

Fiber 🌽#

这里我们来到最后的嵌套层级!Fiber 是一种非常轻量级的协作并发机制。与线程不同,Fiber 不是抢占式调度的;相反,它们显式地来回传递控制权。Fiber.new 接受你将在 Fiber 中执行的块。从那里,你可以使用 Fiber.yieldFiber.resume 来控制 Fiber 之间的来回切换。正如我们之前看到的,Fiber 在同一个 Ruby 线程内运行(所以它们共享相同的内存空间)。就像本博客文章中强调的每个其他概念一样,你应该将 Fiber 视为一个非常低级的接口,我会避免基于它们构建大量代码。对我来说唯一有效的用例是生成器。使用 Fiber,创建一个惰性生成器相对容易,如下面的代码所示。

def fibernnacci
  Fiber.new do
    a, b = 0, 1
    loop do
      Fiber.yield a
      a, b = b, a + b
    end
  end
end

fib = fibernnacci
5.times do
  puts Time.now.to_s
  puts fib.resume
end
2024-10-19 15:58:54 +0200
0
2024-10-19 15:58:54 +0200
1
2024-10-19 15:58:54 +0200
1
2024-10-19 15:58:54 +0200
2
2024-10-19 15:58:54 +0200
3

正如你在这个输出中看到的,代码只在需要消费时才惰性生成值。这允许你的工具箱中有有趣的模式和属性。

再次强调,由于它是低级 API,在你的代码中使用 Fiber 可能不是最好的主意。最知名的大量使用 Fiber 的 Gem 是 Async Gem(由 Falcon 使用)。

总结#

Ruby 提供了几种并发模型,每种都有适合不同任务的独特特征。

进程提供完全的内存隔离,可以在 CPU 核心上并行运行,使它们非常适合需要完全分离但资源密集的任务。
Ractor,在 Ruby 3 中引入,也在同一进程内提供带有内存隔离的并行性,通过在 Ractor 之间传递消息来实现更安全的并行执行。
线程比进程更轻量,在同一进程内共享内存,可以并发运行,但需要仔细同步以避免竞态条件。
Fiber 是最轻量的并发机制,通过手动让出控制来提供协作多任务。它们共享相同的内存,最适用于构建生成器或协程,而不是并行执行。

有了这些知识,你现在有论据参与永无止境的 Puma(线程优先方法)vs. Unicorn(进程优先方法)辩论。只要记住,讨论这个话题就像试图解释 Vi 和 Emacs 之间的区别!找出哪一个是赢家是留给读者的练习![2]


脚注:

  1. 关于 .map (&) 语法的必读内容 ↩︎

  2. 剧透:这取决于情况 ↩︎

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。