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. 劇透:這取決於情況 ↩︎

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。