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 は、特に Ruby on Rails フレームワークと組み合わせて、Web 開発に頻繁に使用されます。オブジェクト指向、関数型、命令型プログラミングパラダイムをサポートしています。

最も有名で広く使用されている Ruby 仮想マシンは Matz Ruby Interpreter(別名 CRuby)で、Ruby の創始者 Yukihiro Matsumoto(別名 Matz)によって開発されました。他のすべての Ruby 実装、例えば JRubyTruffleRuby などは、このブログ記事の議論の範囲外です。

MRI はグローバルインタープリタロック(Global Interpreter Lock)を実装しており、これは同時に 1 つのスレッドのみが実行されることを保証するメカニズムであり、真の並行性を効果的に制限します。言い換えれば、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 ブラウザは、2 つの並行して実行されているプロセスです。

したがって、Ruby プロセスを並行して実行するには、2 つのターミナルウィンドウを開き、それぞれのウィンドウでプログラムを実行するだけです(またはプログラム内で fork を実行することもできます)。

この場合、スケジューリングはオペレーティングシステムによって調整され、プロセス A とプロセス B の間のメモリは隔離されています(例えば、Word があなたのブラウザのメモリにアクセスできることを望まないでしょう?)。

プロセス A からプロセス B にデータを渡したい場合は、パイプ、キュー、ソケット、シグナルなどのプロセス間通信メカニズムが必要です。または、1 つが読み取り、もう 1 つが書き込む共有ファイルのような、より単純なもの(その場合、競合状態に注意が必要です!)。

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)の制約を受けます。これは、同時に 1 つのスレッドのみが 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 = []

# @@count 変数を増やす 10 のスレッドを作成
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 操作であるため、別のスレッドへのコンテキストスイッチを強制します。これにより、コンテキストが 1 つのスレッドから別のスレッドに切り替わるときに、@@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(スレッド優先アプローチ)対 Unicorn(プロセス優先アプローチ)議論に参加するための根拠を持っています。これについて議論することは、Vi と Emacs の違いを説明しようとするようなものです!どちらが勝者かを見つけるのは読者の練習に任せます![2]


脚注:

  1. 必読:.map (&) 構文について ↩︎

  2. ネタバレ:状況によります ↩︎

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。