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-rubygem のようにスレッドセーフとなるライブラリを使う - 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.