本記事は人間であるkojix2が、DeepWikiとChatGPTに交互に呼び出して作成した内容をベースにしていますが、人間であるkojix2が全文に目を通した上で編集や校正を行っています。(内容の正しさを保証するものではありません)
Crystal の並列処理は Fiber(協調的・軽量) を基本とし、必要に応じて Thread(OS スレッド) を利用するハイブリッドモデルです。
2024〜2025 年頃から急速に整備が進んでいる ExecutionContext が、Fiber を複数スレッドに安全に広げるための新しい抽象レイヤを提供しています。
この記事では、最新の Crystal の並列実行モデルを整理します。
並列実行を有効にしてビルドする
2025年11月19日現在では、以下の2つのフラグを使用する必要があります。
-
-Dpreview_mt: Fiberが並列実行されるようになる -
-Dexecution_context: ExecutionContext が利用できるようになる
crystal build -Dpreview_mt -Dexecution_context program.cr
Crystalの並列実行はプレビュー版となっていますが、リリースから6年以上経過しており問題なく使えるケースが多いです。
Crystal の並行・並列処理の全体像
Crystal には大きく5つの実行形態があります:
| モデル | 実行単位 | 特徴 |
|---|---|---|
| Fiber (default) | Fiber(軽量スレッド) | 協調的、I/O で自動切替、非常に軽量 |
| ExecutionContext::Concurrent | Fiber グループ | 1 スレッド上で順次実行(シリアルだが Fiber 化できる) |
| ExecutionContext::Parallel | Fiber グループ | 複数スレッドで実行、最も高度な並列処理 |
| ExecutionContext::Isolated | 1 Fiber + 1 専用スレッド | GUI ループ・FFI のブロッキング呼び出し向け |
| Thread | OS スレッド | 真の並列実行、重い処理向け |
標準的なデザインは以下の通りです。
- 基本は Fiber を使う
- 並列性が必要なところだけ ExecutionContext を使う
- Thread を直接使うのは特殊ケースのみ
Fiber と I/O の協調的スケジューリング
Fiber は協調的実行モデルで、並列実行が無効な時は
I/Osleep-
Channelreceive/send Fiber.yield
などが起きた時だけ切り替わります。( Fiber.suspend が呼び出されて中断されます)
スタック1は 8MiB の仮想サイズですが、予約のみで、実メモリ使用は 4KiB 程度で軽量です。
I/O バウンドな処理は Fiber に載せるのが Crystal の基本姿勢です。2
背景知識 Thread / Scheduler / Fiber
Crystalでは、スレッドごとに独自の Crystal::Scheduler を持ち、そのスレッド上で実行されるファイバーを管理します。
メインスレッドの作成と初期化
メインスレッドはプログラムの起動時にOSによって自動的に作成されます。その後 Thread.current が呼び出されたときにメインスレッド用の Thread オブジェクトが作成されます。メインスレッドのスタックアドレスが、stack_address メソッドで取得されます。OSがプロセス起動時に割り当てた実際のスレッドスタックです。
メインFiberの作成
Thread オブジェクトが初期化されるときに、メインFiberが同時に作成されます。メイン Fiber は特別なコンストラクタ Fiber.new(stack : Void*, thread) を使用して、OSのスレッドスタックを利用します。通常の Fiber とは異なり、makecontext は呼び出されず、すでに実行中のコンテクストを使用します。
スケジューラーの遅延初期化
メインスレッドのスケジュラーは、Thread#scheduler が呼び出されたときに初期化されます。スケジューラーは
-
@event_loopプラットフォーム固有のイベントループ -
@stack_poolファイバースタックの再利用プール -
@runnable実行可能なファイバーのキュー -
@mainスレッドのメインファイバー
を持ちます。
デフォルトのスレッド構成
ExecutionContext と preview_mt を利用しない状態では、メインスレッドが一つだけ存在します。メインスレッドでは自身の Crystal::Scheduler インスタンスを持ち、これがすべてのファイバーを管理します。
新しいFiberのスタック確保
新しいFiberが生成されると、Fiber::StackPoolからスタックメモリが取得されます。Fiberが終了すると、そのスタックはStackPool.releaseを通じてプールに返却され、次のFiberで再利用されます。スタック割当では 8MiBの仮想アドレス空間が予約されます。スタックの最下部のページのみ(4KiB)が物理メモリにコミットされます。スタックが成長してガードページに到達すると、そのページのガードステータスが解除され、新しいガードページがコミットされます。予約済みページがなくなるまでそれが続きます。
ExecutionContext による並列実行
ExecutionContext は Fiber をまとめて実行する「仮想スレッドグループ」です。
ExecutionContext::Concurrent
従来のFiberと同じ同じシリアル実行ですが、安全で扱いやすいです。
ctx = Fiber::ExecutionContext::Concurrent.new("workers")
- コンテキスト内の Fiber は同時に1つしか実行されません
- そのため shared 変数へのアクセス競合が起きません(ただし「推奨安全性」として Mutex/Atomic は使うべき)
「並列化は不要だが、処理を Fiber にしたい」場合に適しています。
ExecutionContext::Parallel
複数スレッドで並列実行する。
ctx = Fiber::ExecutionContext::Parallel.new("workers", 8)
- 複数の scheduler(OS スレッド)が動く3
- コンテキスト内の Fiber は任意のスレッドに移動して実行される4
- 並列性があるため shared mutable state は
Atomic/Mutex必須
Crystal が目指す「安全で高速な並列実行」の中心的機能です。
ExecutionContext::Isolated
1 Fiber = 1 専用スレッド
gui = Fiber::ExecutionContext::Isolated.new("GUI") do
Gtk.main
end
gui.wait
- 単一 Fiber が OS スレッドを専有
- ブロッキング I/O(例:GUI イベントループ、FFI のブロッキング呼び出し)を安全に使える
- コンテキスト内に追加 spawn はできない(強制的に default context に行く)
GUIアプリケーションのメインループや、IOバンドルのブロッキングがあるC関数を呼び出すFFIなどに適しています。
ExecutionContext を使わないデフォルトの Fiber について
ExecutionContextを指定しない場合、Fiberはデフォルトの ExecutionContext(Fiber::ExecutionContext.default)で実行されます。デフォルトの ExecutionContext は Parallel ですが、初期並列度は1に設定されているため、Concurrent と同じ動作をします。
Channel と WaitGroup の基本パターン
Crystal の並列処理は Channel + WaitGroup という Go に似たパターンが基本です。
Producer-Consumer(Parallel)
consumers = Fiber::ExecutionContext::Parallel.new("consumers", 8)
channel = Channel(Int32).new(64)
wg = WaitGroup.new(32)
result = Atomic.new(0)
32.times do
consumers.spawn do
while value = channel.receive?
result.add(value)
end
ensure
wg.done
end
end
1024.times { |i| channel.send(i) }
channel.close
wg.wait
p result.get # => 523776
- Channel による通信
- WaitGroup による同期
- Atomic による shared state の安全な更新
Crystal の並列実行の基本形。
並列実行される32個のconsumer Fiberが、channelから受信した1024個の整数値(0〜1023)をアトミックに加算し、その合計(523776)を計算する
Concurrent における shared 変数の保護
Concurrent はシリアル実行なので競合は起きないが、Crystal 公式は Atomic / Mutex を使うのが望ましいとしています。
Atomic / Mutex / SpinLock
Atomic
複数のスレッドから同時にアクセスされても安全に値を読み書きできる変数で、競合状態を防ぐための基本的な同期プリミティブです。
- LLVM の atomic 命令に直接マッピング
- compare_and_set, add, sub, get, set
- Acquire / Release / Relaxed など C/C++ と同じメモリオーダー
Mutex
複数のFiberが同時に実行してはいけないコード領域(クリティカルセクション)を保護するロックで、一度に1つのFiberだけが実行できるように制御します。
- Fiber セーフ
- Checked / Reentrant / Unchecked の3モード
- 再入禁止がデフォルト(安全)
mutex = Mutex.new
shared_array = [] of Int32
10.times do |i|
spawn do
mutex.synchronize do
# このブロック内は一度に1つのFiberのみ実行
shared_array << i
sleep 0.001.seconds
end
end
end
sleep 1.second
puts shared_array.size # => 10
手動で lock / unlock する場合の例
mutex = Mutex.new
counter = 0
10.times do
spawn do
mutex.lock
begin
counter += 1
sleep 0.001.seconds
ensure
mutex.unlock # 必ずunlockする
end
end
end
sleep 1.second
puts counter # => 10
SpinLock
非常に短時間のロックに特化した軽量なロックです。待機中にCPUを使い続ける(スピンする)ため、長時間のロックには不向きです。
- 非常に短いクリティカルセクション用
- preview_mt / win32 のみ有効
SpinLockは、Crystal::Scheduler, Crystal::ThreadLocalValue, Crystal::Once, Mutex, WaitGroup, EventLoop::Polling, Fiber::StackPool などの実装に使用されています。SpinLock をユーザーがコードで直接使用するシーンはほとんどありません。
標準ライブラリで注意した方がいいところ
以下は Crystal 標準ライブラリの中で、完全な thread safety が保証されていない可能性がある領域であり注意が必要です。
何が shared 変数として競合するか?
ここまで shared 変数という言葉が出てきましたが、Crystalはユーザーが直接使えるグローバル変数が存在しないので、もっとも典型的なshared変数は、クラス変数です。
- クラス変数:常に shared 変数(変数の種類で決まる)
- インスタンス変数とローカル変数:spawn されたときに複数の Fiber やスレッドから参照されるかどうかで決まる
spawn にキャプチャされれば、ローカル変数も shared 変数になる可能性があります。
ENV
- Unix の getenv/setenv/unsetenv の安全性は環境依存
- 並列で変更するのは推奨されない
Crystal Forum でも議論されています。
クラス変数
Crystal では @[ThreadLocal] アノテーションを使用して、クラス変数をスレッドローカルにできます。
class Foo
@[ThreadLocal]
@@var = 123
def self.var
@@var
end
end
この場合、各スレッドが独立した @@var のコピーを持つため、あるスレッドで値を変更しても他のスレッドには影響しません。
@[ThreadLocal] がないクラス変数は shared になります。この場合並列更新には Atomic / Mutex を使う必要があります
IO(File, Socket, STDOUT/ERR)
複数スレッドから同一 IO へ同時操作すると安全性は保証されない可能性がある。
Logger
Logger も内部で IO を使う。複数スレッドから同一 Logger への書き込みは安全とは限らない。
何か問題を発見したら報告をしよう
CrystalはPythonやJavaといった言語と比較すれば非常に利用者の少ないプログラミング言語です。ユーザーのレポートはとても貴重で価値があります。Crystal Forum や GitHub issue へ積極的にバグ報告することで、言語やライブラリを改善し続けることが大切です。
Thread を使うべきケース
Thread は OS のネイティブスレッドを直接表現します。低レベルな制御が必要な場合に使用することができます。
ExecutionContext を使わず Thread を直接使うべきケースはほとんどありません。
以下のようなケースでは選択肢になることがあるようです。
- 計算集約タスクを並列化したい
- FFI がブロッキングで Fiber を中断できない 5
- C ライブラリが thread-local な初期化を必要とする
Thread::Channel を使うと、スレッド間の安全な通信ができる。
FFI(C ライブラリ呼び出し)と並列実行
C ライブラリが thread-safe であるとは限らないため、以下のようなパターンに従うと安全だと考えられます。
-
Mutexで包む -
ExecutionContext::Isolatedコンテキストに隔離する - 専用 Thread + Thread::Channel
- ThreadLocal 状態を使う
まとめ
Crystal の並列実行は現在、大きな進化の途上にあります。これまでIOバンドルな処理で並行実行を実現するために使われてきた Fiber に加えて、ExecutionContext::Parallel で本格的な並列処理が可能になりました。Atomic / Mutex / Channel / WaitGroup を使うことで Go ライクな安全な並列処理を構築できます。Execution::Isolated は GUI / FFI で有効です。Thread はOSスレッドを直接扱うような特殊なケースで使えます。標準ライブラリには thread safety が曖昧な部分があるので注意が必要です。
Crystal の並列実行における実践ガイドライン
- I/O は
Fiberに任せる- Crystal の I/O モデルは
Fiberと密接に統合されているので特に何もする必要なし。
- Crystal の I/O モデルは
- CPU バウンドは Parallel または Thread
-
ExecutionContext::Parallelが第一選択肢。
-
- shared state は
AtomicまたはMutexで守る-
ENVやLoggerなどのグレーゾーンは保守的に扱う
-
-
-Dpreview_mtと-Dexecution_contextを明示的に使用してテストする
この記事は以上です。最後までお読み頂きありがとうございました。
-
スタックは、全てのFiberが持つメモリ領域である。各Fiber は @stack インスタンス変数を持ち、独自のスタック領域を保持している。スタックには、値型(構造体など)とGCで管理される参照型オブジェクトへのポインタの両方が配置される。スタックという名前がついているが、OSから見ると「ヒープ領域」であることに注意が必要。 ↩
-
RubyのThreadとよく似ていますね。 ↩
-
Parallel コンテキストにおける scheduler とは
Fiber::ExecutionContext::Parallel::Schedulerクラスのインスタンスで、個別の Fiber 実行を担当する実行単位です。ローカルキューを持ち、実行可能な Fiber を管理します。メインループ(run_loop)で Fiber を探して実行します。例えばParallel.new("workers", 8)では、8つの scheduler が作成され、それぞれが独立した OS スレッドで動作可能になります。これにより、最大8つの Fiber が同時に並列実行できます。 ↩ -
Fiber がスレッド間を移動する際、実際に移動するのは実行コンテキスト(レジスタとスタックポインタ)のみです。Fiber のスタックメモリは移動しません。このメモリ領域は Fiber の生存期間中固定されています。新しいスレッドで Fiber が再開されると、保存されたスタックポインタがロードされ、元のスタックメモリ領域を指します。 ↩
-
FFIの関数がCPU集約的な処理である場合には、むしろブロッキングされるのが望ましい挙動と考えられます。 ↩