valid,invalid

関心を持てる事柄について

Rack middlewareとスレッドセーフ

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 され、同一プロセス内では使い回されるのでスレッドセーフでない。

スレッドセーフにするには以下の方法がある。

  1. 必要がないなら Rack middleware 内で mutation を行わない
  2. instance のライフサイクルがリクエスト単位になるようcall内でインスタンスを clone する
  3. mutation の際にconcurrent-ruby gem のようにスレッドセーフとなるライブラリを使う
  4. Rack middleware を freeze する

冒頭のコードは thread safety のためにdupしている、というのが答えになる。

野生のdup

まず、dupをしている実装例をいくつか見る。

f:id:ohbarye:20210202011744p:plain
勢いのある`call!!`

Rack::RecursiveRack::Lintのリンク先の commit log を見るとスレッドセーフのために Rack middleware instance をdupしているとのこと。

dupしたあとに呼ばれた_call内でのインスタンス変数の操作はdupされたインスタンスに対して行われる、というのは RubyObject#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のnewproc内で行われていることが 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 frozenraiseされる。

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

参考記事


This article is for ohbarye Advent Calendar 2020.