Rubyによる並行並列プログラミング
笹田 耕一<ko1@st.inc>今日のトピック• Rubyでの並行・並列処理のための仕組み• プロセス、Ractor、スレッド、Fiber scheduler, Fiber• Ruby 3.3 から導入される M:N スケジューラでいろいろ軽量に• 共有データを複数スレッドで読み書きするなら同期は大変• 大変なので、なるべくやらないのがよい• 適切なロックが必要• Transactional Memory を用いた Ractor::TVar という別解もあるよ自己紹介：笹田耕一• Ruby interpreter developer employed bySTORES, Inc. (2023~) with @mametter• YARV (Ruby 1.9~)• Generational/Incremental GC (Ruby 2.1~)• Ractor (Ruby 3.0~)• debug.gem (Ruby 3.1~)•…

• Ruby Association Director (2012~)

プログラミング• コンピューターに仕事（タスク）を処理してもらうことA

B# Ruby で書くとdoA()
doB()
doC()

C

時間

並列処理（Parallel processing）• 複数の仕事を同時にやってもらうこと• A, B, C がそれぞれ関係ない仕事の場合、同時にやってもらえる• たとえば B は A の結果が必要である場合、A の後にやらないといけないから• 同時にやってくれると速く終わる
A

B

C
時間

並行処理（Concurrent processing）• 複数の仕事をやってもらうこと• 同時にやってもいいし、同時じゃなくてもいい• 並列処理は並行処理の一種• まっていれば複数の仕事がいつか終わる• 特に指定がない場合、順番はわからない• I/O などで待ちになると別の処理を実行してもいい
A

C(1)

B

C(2)

C の処理が一時中断して B を先に終わらせた例
時間

逐次・並行・並列プログラミング• 逐次プログラミングABC• “ふつう” にかいたもの• 順番に実行→順番にしかできない• 並行プログラミング

A

C(1)BC(2)• 独立した複数の処理をそれぞれ独立に書くと書きやすい• システムがいい感じに実行• C が中断したら B をやる、みたいなのは逐次では書きづらい• 並列プログラミング• 独立した複数の処理を同時に実行• プログラムを高速に実行したい

ABC時間

Quoted from “Learn Ractor” by Masatoshi SEKI (RubyKaigi 2023)

Quoted from “Learn Ractor” by Masatoshi SEKI (RubyKaigi 2023)

Ruby における並行並列プログラミングの道具

並行並列処理のためのRubyの道具• マルチプロセス• Ractor (from Ruby 3.0)
• Thread• Fiber scheduler (from Ruby 3.0)
• Fiber「何を選ぶか」は「何を並行処理したいか」で決まる

大きな独立した処理

小さな処理マルチプロセス• 道具• Process クラス• dRuby• Active Job, Sidekiq, …
• Unicorn, pitchfork• 特徴• 並列実行可能• データの共有は read only（copy on write）• I/O などでコミュニケーション• 互いに影響が少ないので強制終了などさせやすい• OSプロセスの管理は面倒くさいのでツールに任せるべきマルチスレッド• 道具• Thread クラス• Puma• Concurrent-ruby• 特徴• 並行実行可能• I/O や時間などで自動的に切り替わるモデル• 並列実行はしない（CRubyでは）• データはすべてスレッド間で共有• 全体のメモリは小さく収めることが可能• 1 Rubyスレッドにつき1ネイティブスレッド（OSスレッド）• 大量に作りづらい• プロセスよりは軽いFiber• 道具• Fiber クラス• 他の言語では coroutine という用語も• 特徴• 手動で並行処理を記述可能• すべて自分で記述• データはすべて共有• メモリしか使わないので軽量• 大量に作っても比較的大丈夫

並行処理の道具
データ共有
並列実行
生成管理コスト
コンテキスト切り替え

プロセススレッドFiber

×〇×自動〇
×〇
自動〇×◎
手動

並行並列処理のためのRubyの道具• マルチプロセス• Ractor (from Ruby 3.0)
• Thread• Fiber scheduler (from Ruby 3.0)
• Fiber「何を選ぶか」は「何を並行処理したいか」で決まる

大きな独立した処理

小さな処理

Fiber scheduler (Ruby 3.0~)• 道具• Falcon（rack 用サーバ、puma のレイヤに相当）• Async• 特徴• Fiber をスレッドのように使うための仕組み• ブロックする処理のタイミングで Fiber を切り替えるスケジューラを Ruby で記述可能• 記述可能だが、既存のスケジューラを使うのがよい• だいたいスレッドと同じような使い心地• スケジューラ実装としての async と、それで Rack ウェブアプリケーションを記述するための falcon フレームワーク

Ractor (Ruby 3.0~)• 道具• Ractor クラス• （まだあまり道具がない…）• 特徴• 並行並列処理が可能• Ractor 間でデータの共有を意図的にできないようにすることで、安全な並列処理を実現• データの共有には強い制限• ユーザアプリケーションを書き換えないといけない並行処理の道具

データ共有
並列実行
生成管理
コスト
コンテキスト
切り替えプロセス

Ractor

スレッド

Fiberscheduler

Fiber×〇×△〇〇〇×〇〇
×
◎

〇×
◎

自動

自動

自動

自動

手動

M:N Scheduler の導入（Ruby 3.3~）• 道具• Thread (および Ractor)• 環境変数 “RUBY_MN_THREADS=1” を指定して実行• 特徴• スレッドの実装を、Fiber scheduler のように、ネイティブスレッドではなくユーザレベルスレッドを用いることで、軽量に管理• 大量のスレッド（Ractor）を生成可能に• M:N スケジューラについて詳細:
Rubyの並列並行処理のこれまでとこれから - クックパッド開発者ブログ

並行処理の道具
プロセス
データ共有並列実行生成管理
コスト
コンテキスト
切り替え×〇
×自動

Ractor+M:N

スレッド+ M:N△〇〇
×〇 → ◎ 〇→ ◎

自動

自動

Fiber
scheduler

Fiber〇×◎〇×◎

自動

手動

理論的にはあまり変わらない
あとはアプリへの組み込みやすさ、実装の作りこみの差https://gist.github.com/ko1/ff2cc7d83ae5f73a23ad3ba69a930b58
ruby 3.3.0dev (2023-11-07T04:38:37Z master d6f5c27525)

BenchmarkEmpty concurrent tasks

def task = nil

10k empty tasks
2.282

1.000

Seconds

0.800
0.600
0.400

Ruby 3.3~

0.200
0.000

wait
makeseq

fibasync

threads

0.000
0.000

0.007
0.013

0.0000.105

0.001
0.645

seq: Sequential execution
fib: manual Fiber scheduling (=~ seq)
async: Fiber scheduler with async.gem

makethreads/ threads/ threads/
NGCMN

MN,NGC

0.001
0.234

0.001
0.061

0.001
0.054

wait

ractors
0.003
2.279

ractors/
NGC
0.002
0.743

MN: Enable MN Threads
NGC: Disable GC

Benchmark
Concurrent HTTP requests

def task =URI.open(...){|f| f.read}

Seconds

4k http requests
3.000
2.500
2.000
1.500
1.000
0.500
0.000
wait
makeI/O 待ちをうまく扱い最高速

open-uri がまだ動かない

N/Aseqfibasync

threads

threads/MN

ractors

0.000
1.986

1.935
0.005

0.000
0.925

1.827
0.961

1.437
0.026

0.000

make

wait
Ruby 3.3 出荷までにチューニング

Thread API と同期

Thread の生成• Thread.new{ … } で … を並行に処理するスレッドを生成• … が終わるまでスレッドオブジェクトは生き続ける

t1 = Thread.new do
doA()
end

t2 = Thread.new do
doB()
end

Thread の待ち合わせ• Thread#value もしくは Thread#join でスレッド終了を待つ• #value はブロックの返り値を返す
t1 = Thread.new{ doA() }t2 = Thread.new{ doB() }p t1.value #=> doA() の結果
p t2.value #=> doB() の結果• Queue / SizedQueue を利用する• より汎用的な待ち方

q = Queue.new
t1 = Thread.new{ q << doA() }t2 = Thread.new{ q << doB() }2.times{ p q.pop } # ２つの実行結果を待つThread のエラーハンドリング• スレッド実行時にエラーでスレッドが死んだ場合、Thread#value もしくは Thread#join でエラーを返す
t1 = Thread.new{ raise "foo" }p t1.value #=> foo (RuntimeError)• エラーレポート• Thread#abort_on_exception = true (default: false)• エラーでスレッドが死んだらRubyプロセスを終了する• Thread#report_on_exception = true (default: true)• エラーがスレッドが死んだら標準出力にエラーを表示• 真面目にやるなら、エラーで死なないようにエラー処理を記述

Threadの中断• Thread#kill (#terminate) でスレッドを無理やり中断• Thread#raise でスレッドに例外を発生させ中断• どういうタイミングで中断されるかわからないので、非推奨
t1 = Thread.new{ while true; end }# t1 は無限ループ
t1.killt1.joinThreadの同期• データの読み書きの一貫性保持• 同時に読み書きするとまずいことが起こる→ 同期 (synchronization) が必要

ko1@point: 0class Person
attr_accessor :point
def initialize@point = 0end
def inc n@point += n
end
end
ko1 = Person.newThreadの同期• スレッド 1 とスレッド 2 が同時にポイント追加（10と20）
T1

T2

ポイントを 10 追加したい

ポイントを 20 追加したい

ko1@point: 0

t1 = Thread.new do
ko1.inc(10)endt2 = Thread.new do
ko1.inc(20)end
t1.join; t2.join
p ko1.point #=> 30…?

Threadの同期• スレッド 1 とスレッド 2 が同時にポイント追加（10と20）
T1

T2

ポイントを 10 追加したい

ポイントを 20 追加したい(1) 現在値は0(2) 足して10(3) 10を書き込み

ko1@point: 0→ 10 → 30

t1 = Thread.new do
ko1.inc(10)endt2 = Thread.new do
ko1.inc(20)end
t1.join; t2.join
p ko1.point #=> 30…?

(4) 現在値は10(5) 20足して30(6) 30を書き込み問題ないケース

Threadの同期• スレッド 1 とスレッド 2 が同時にポイント追加（10と20）
T1

T2

ポイントを 10 追加したい

ポイントを 20 追加したい(1) 現在値は0(2) 足して10(5) 10を書き込み

ko1@point: 0→ 10 → 20

t1 = Thread.new do
ko1.inc(10)endt2 = Thread.new do
ko1.inc(20)end
t1.join; t2.join
p ko1.point #=> 30…?

(3) 現在値は0(4) 20足して20(6) 20を書き込み問題あるケース

Threadの同期
ロックの利用• スレッド 1 とスレッド 2 が同時にポイント追加（10と20）
T1

T2

ポイントを 10 追加したい(1) ロック!
(2) 現在値は0(3) 足して10(4) 10を書き込み(5) アンロック！
ko1@point: 0→ 10 → 30

ポイントを 20 追加したい(6) ロック!
(7) 現在値は10(8) 20足して30(9) 30を書き込み(10) アンロック！

class Person
def initialize@point = 0@m = Mutex.new
end
def inc n@m.synchronize{@point += n }endend
ロックしているスレッド
のみ書き込みThreadの同期
ロックによる同期の問題点• 使うのを忘れちゃう• ついうっかり• ロックしなくても使える（読める・書き込める）• 問題が発生しても、気づきづらい• 大体の場合、問題が発生しない• 発生しても、微妙な差異に気づくのが困難（ポイントが 20 になっても…）• プログラムが複雑になると、気づくのが無茶苦茶難しい• Rust などでは型で強制• 複数のロックを組み合わせるのが困難• 容易なデッドロック

Threadの同期
複数ロックの問題• ポイントを移したい！！
T1

T2

ko1 から yuki へポイント移動したい
yuki から ko1 へポイント移動したい
ko1

yuki
@point:100@point: 100class Person
def pass_to(other, n)@point -= nother.inc n
end
end
t1 = Thread.new{
ko1.pass_to(yuki, 20) }t2 = Thread.new{yuki.pass_to(ko1, 30) }ロックしないと一貫性の問題?

Threadの同期
複数ロックの問題• ポイントを移したい！！
T1

T2(1) ko1.lock(2) @point -= n(4) yuki.lock(3) yuki.lock(5) @point -= n(6) ko1.lock

ko1class Person
def pass_to(other, n)@m.synchronize{@point -= nother.inc n}
end
end

yuki
@point:100@point: 100

相手のロックがすでにとられてる→デッドロック！！

Threadの同期
複数ロックの問題• 解決策• ロックの順番を毎回同じにする• 例: Ko1 <-> Yuki なら、K のほうが辞書順ではやいので、K と Y に関連する処
理は必ず K->Y の順にする• 相手がロックされているのを検出したら、一度ロックを開放してやり直す• 個々のロックを用いず、ジャイアントロックを用いる• Ruby でこんな並行処理を書かない• この手のものは並行処理しない• スレッドは使わない（マルチプロセスや Ractor の利用を検討する）• データに触ることができる専用スレッドを用意する、など• 外部データベースに任せる別解: Transactional memory による同期• 悲観的ロック vs. 楽観的ロック• 悲観的：他が変更するかもしれないから変更不可にして処理• 楽観的：他も変更するかもしれないけど、もしダメならやりなおそう• データベースの（楽観的）トランザクションで著名• 現在の値で処理をとりあえず実行• 最後のコミット時、すでにほかがコミットしていた（衝突した）とき、処理をやりなおす• コンポジション可能（トランザクションのネストが可能）Transactional variable による
ポイントシステム• $ gem install ractor-tvar https://github.com/ko1/ractor-tvar• 名前のとおり、Ractor 間で利用可能 class Person
attr_accessor :point• TVar#atomically で囲むとトランザク
def initializeション• トランザクション内の TVar#value への書き込み、ブロック終了時にコミット• コンフリクトしていたら失敗、ブロックを再実行• atomically ブロック外（トランザクション外）では値にアクセスできな
い → 忘れない@point = Ractor::TVar.new(0)enddef inc n@point.atomically do
tv.value += nend
もしほかのスレッドがend
value を書き換えていendたらコミット失敗
ブロック再実行Transactional variable による
ポイントシステム• トランザクションはネスト可能• トランザクションの順序を問わない
ので、ロックのようなデッドロックの心配なし• コンフリクトしたら一番外側のトランザクションから再実行

class Person
def pass_to other, n@point.atomically doother.point.atomically do@point.value -= nother.point.value += nend
end
endendまとめ• Rubyでの並行・並列処理のための仕組み• プロセス、Ractor、スレッド、Fiber scheduler, Fiber• Ruby 3.3 から導入される M:N スケジューラでいろいろ軽量に• 共有データを複数スレッドで読み書きするなら同期は大変• 適切なロックが必要• 大変なので、なるべくやらないのがよい• Transactional Memory を用いた Ractor::TVar という別解もあるよRubyによる並行並列プログラミング
笹田 耕一<ko1@st.inc>