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 實現,如 JRuby、TruffleRuby 等,都不在本博客文章的討論範圍內。
MRI 實現了全局解釋器鎖(Global Interpreter Lock),這是一種確保同一時間只有一個線程運行的機制,有效地限制了真正的並行性。言外之意,我們可以理解 Ruby 是多線程的,但並行性限制為 1(或者可能更多 👀)。
許多流行的 Gem,如 Puma、Sidekiq、Rails、Sentry 都是多線程的。
Process 💎、Ractor 🦖、Threads 🧵 和 Fibers 🌽#
這裡概述了 Ruby 中處理並發和並行的所有複雜層次(是的,它們不是同一回事)。讓我們深入探討每一個。
你在一個模擬的模擬中... 在另一個巨大的模擬中!* 笑得更厲害 *
默認情況下,所有這些嵌套結構都存在於你能想到的最簡單的 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.yield
和 Fiber.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]
腳注:
-
劇透:這取決於情況 ↩︎