Rack Middleware で以下のようなイディオムを見かけることがあるが何のためにdup
しているかを理解していなかったので調べてみた。
class MyMiddleware def call(env) dup._call(env) end def _call(env) # do whatever end end
結論
Rack middleware の instance は rack server プロセスが最初にリクエストを処理するときに initialize され、同一プロセス内では使い回されるのでスレッドセーフでない。
スレッドセーフにするには以下の方法がある。
- 必要がないなら Rack middleware 内で mutation を行わない
- instance のライフサイクルがリクエスト単位になるよう
call
内でインスタンスを clone する - mutation の際に
concurrent-ruby
gem のようにスレッドセーフとなるライブラリを使う - Rack middleware を freeze する
冒頭のコードは thread safety のためにdup
している、というのが答えになる。
野生のdup
まず、dup
をしている実装例をいくつか見る。
Rack::Recursive
とRack::Lint
のリンク先の commit log を見るとスレッドセーフのために Rack middleware instance をdup
しているとのこと。
dup
したあとに呼ばれた_call
内でのインスタンス変数の操作はdup
されたインスタンスに対して行われる、というのは Ruby のObject#dup
の挙動を知っていればわかる。
しかし、なぜそうしなければスレッドセーフにならないのか。それを知るには Rack middleware がどうやって生まれ、育ち、死んでいくのかを知る必要があった。
Rack middleware instance の"半生-ライフサイクル-"
Rack middleware の instance は rack server プロセスが最初にリクエストを処理するときに initialize され、使い回される。リクエストのたびにインスタンスを生成しているわけではない。
シンプルに以下のようなconfig.ru
を書いてrackup
するとリクエスト発生前の instance 生成状況がわかる。
# config.ru require 'rack' class MyMiddleware def initialize(app) "MyMiddleware is initilized" @app = app end def call(env) puts "==call==" puts "object_id: #{object_id}" puts "RackDemo instance count: #{ObjectSpace.each_object(RackDemo).count}" puts "MyMiddleware instance count: #{ObjectSpace.each_object(MyMiddleware).count}" @app.call(env) end end class RackDemo def call(env) [200, {"Content-Type" => "text/plain"}, ["yay"]] end end puts "==Before run==" puts "RackDemo instance count: #{ObjectSpace.each_object(RackDemo).count}" puts "MyMiddleware instance count: #{ObjectSpace.each_object(MyMiddleware).count}" use MyMiddleware run RackDemo.new puts "==After run==" puts "RackDemo instance count: #{ObjectSpace.each_object(RackDemo).count}" puts "MyMiddleware instance count: #{ObjectSpace.each_object(MyMiddleware).count}"
アプリケーションの instance はnew
してるので当然増えているが、use
呼び出した時には rack middleware の instance はまだ存在しない。(渡したmiddlewareのnew
はproc
内で行われていることが use
の実装 からわかる)
(warmup
により予め生成しておくことはできる)
==Before run== RackDemo instance count: 0 MyMiddleware instance count: 0 ==After run== RackDemo instance count: 1 MyMiddleware instance count: 0
curl http://localhost:9292
してやると以下が表示されるので instance が生成されたことがわかる。また、何度リクエストを送っても instance 数は 1 のままであり、object_id
も変わらないため、instance が使い回されていることがわかる。
MyMiddleware is initilized ==call== object_id: 800 RackDemo instance count: 1 MyMiddleware instance count: 1
https://github.com/rack/rack/blob/5ce5b2ccb151c62652e25b4711218ede11497cc3/lib/rack/builder.rb#L148
multi-thread 環境では単一 instance に対して並行に読み書きが行われるので race condition の問題が起きる。だからdup
してリクエストのたびにインスタンスが生成されるようにしてやらないといけないのだった。
スレッドセーフの実現手法
スレッドセーフの実現方法は他にもある。
まず、Rack middleware 内でインスタンス変数を mutate しない場合はdup
を呼び出す必要はない。なので第一の解決策はそもそもmiddleware 内で状態を mutate しないこと。
第二に、dup
する代わりにconcurrent-rubyなどを使ってスレッドセーフなデータ構造を使うこと。
第三に、middleware を freeze すること。rack/rack
は v2.1.0 からfreeze_appを提供している。これを利用する Rack middleware 内で mutation (=スレッドセーフでない操作) を行うとFrozenError: can't modify frozen
がraise
される。
use (Class.new do def call(env) @val += 1 @app.call(env) end freeze_app end)
このコンセプト自体はrack
v2.1.0 以前からrack-freezeとして存在していた (背景: Middleware should be frozen by default)。
なお、rack-freeze
はアプリケーション全体にデフォルトで freeze をかけるポリシーを強制したいなら未だに有用とのこと。
まれに Rack middleware のコードを読み書きすると、一見シンプルなインタフェースに見えながらも深い理解がないとハマる問題がある。
今回は特定の問題に引っかかったわけではないがコードリーディングの最中にわからないことがわかってよかった。
環境
- rack 2.1.0 ~ 2.2.3
参考記事
- https://stackoverflow.com/questions/23028226/rack-middleware-and-thread-safety
- https://crypt.codemancers.com/posts/2018-06-07-frozen-middleware-with-rack-freeze/
- rack3 で thread safety をどうするかの議論 https://github.com/rack/rack/issues/1617
- 長いので全部は読んでいない
This article is for ohbarye Advent Calendar 2020.