banner
Hyacine🦄

Hyacine🦄

【翻訳】Rubyの秘密 ♦️ (1/3):すべてはスレッドに関すること

Ruby は本当に並行言語なのか?!#

原文発表日:2024 年 10 月 20 日
著者:Wilfried
翻訳日:2025 年 6 月 15 日
翻訳者:Hyacine🦄 with agents : )
原文 URL: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)を実装しており、これは同時に 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 ブラウザなど、並行して多くのプロセスを実行しています。

したがって、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. ネタバレ:状況によります ↩︎

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