これはなに?
PumaでRailsをマルチスレッドで運用すると、同一メソッドが同時に実行されたタイミングで、クラス変数/クラスインスタンス変数の値がごっちゃになることがあるよってお話です
前提: Pumaの基本構造
Pumaはマルチプロセス + マルチスレッドのハイブリッド構成
- ワーカープロセス : 複数立ち上がり、それぞれ独立したRailsアプリをメモリにロード
- スレッド : 各ワーカー内で複数スレッドを持ち、リクエストを並列処理する
各ワーカー内でのRailsの挙動
- アプリケーションの初期化: ワーカープロセス起動時にRailsアプリ全体をメモリにロード
- 同一ワーカープロセス内の全スレッドは、同じメモリ空間(クラス定義、クラス変数等)を共有する。
- リクエスト処理:
- クライアントからのリクエストが来ると、空いているスレッドが1つ割り当てられる
- そのスレッド内でRailsのリクエスト処理サイクル(ルーティング→コントローラー→モデル→ビュー)が実行される
- レスポンスを返すとスレッドは解放される
実例
こんなコードのが複数メソッドで同時実行されると、競合が発生する
class UserService
@@request_counter = 0
@processing_users = {}
def self.process_user(user_id)
@@request_counter += 1
@processing_users[user_id] = Time.current
sleep 0.1
@processing_users.delete(user_id)
puts "処理完了: #{@@request_counter}件目"
end
end
クラス変数、クラスインスタンス変数は、Railsのコードが各クラスをロードしたとき、ワーカープロセスのメモリに格納される。
- Railsのコードが各クラスをロードするのは、pumaのworker(プロセス)が起動した後
- ワーカープロセス内の全スレッドが同じメモリ空間を共有している
そのため、 クラス変数、クラスインスタンス変数の値をそれぞれのスレッドが変更すると競合が発生しえる。
補足
MRI Rubyでは、同時に実行できるRubyコードは実質1つのスレッドのみで、DBアクセスやHTTP通信などのI/O待機中は他のスレッドが実行できる。
なので、実際には同時に並列実行されることはないが、上記のような途中にI/O待機がある場合、I/O待機中に、同一メソッドが実行されて、競合が発生する。
対策案
1. 【最推奨】そもそも共有状態を持たない設計
Railsでは各リクエストは独立して処理されるべき。状態をクラスに持たせず、必要な情報はDBやキャッシュで管理すべき。
class UserService
def self.process_user(user_id)
request_id = SecureRandom.uuid
ProcessingLog.create!(user_id: user_id, status: 'processing', request_id: request_id)
perform_business_logic(user_id)
ProcessingLog.find_by(request_id: request_id).update!(status: 'completed')
end
private
def self.perform_business_logic(user_id)
end
end
2. ローカル変数を使用
共有する必要がないデータは、メソッド内のローカル変数として扱う
def self.process_user(user_id)
local_counter = 0
local_state = {}
local_counter += 1
local_state[user_id] = "processing"
local_state[user_id] = "processed"
puts "処理完了: #{local_counter}件目"
end
3. スレッドローカル変数を使用
どうしてもスレッド間でデータを分離したい場合
def self.process_user(user_id)
Thread.current[:counter] ||= 0
Thread.current[:counter] += 1
Thread.current[:processing_users] ||= {}
Thread.current[:processing_users][user_id] = Time.current
puts "処理完了: #{Thread.current[:counter]}件目(このスレッド内)"
end
4. 【最終手段】Mutexで排他制御
スレッドがロックされて、パフォーマンスに影響がでるので、最終手段
class UserService
@@request_counter = 0
@@processing_users = {}
@@mutex = Mutex.new
def self.process_user(user_id)
@@mutex.synchronize do
@@request_counter += 1
@@processing_users[user_id] = Time.current
current_count = @@request_counter
end
perform_business_logic(user_id)
@@mutex.synchronize do
@@processing_users.delete(user_id)
end
puts "処理完了: #{current_count}件目"
end
end
まとめ
- pumaでは、同一worker上のスレッドでは、クラス変数やクラスインスタンス変数は、同一のメモリを参照する
- そのため、スレッドで並列実行される可能性があるメソッドで、クラス変数やクラスインスタンス変数を変更/参照すると、競合が発生する可能性がある
- 厳密には、Rubyではスレッドの同時並列実行は発生しないが、I/O 待機時に、待機しているメソッドと同一のメソッドが実行される可能性はある
- そもそもRailsのようなwebアプリでは、各リクエストが独立して処理されるべきで、状態はDBやキャッシュで管理すべき、もしくはローカル変数を使うべき
- mutexや、スレッドローカル変数は最終手段