around_filter で reset_session するとメモリリーク(memory leak)する
以下のように around_filter 内で reset_session するアクションに対し、大量のアクセスを重ねていくと、メモリ使用量ががつがつ増大していく。
class FugaController < ApplicationController around_filter :foo def index end protected def foo reset_session yield end end
こいつを script/server -e production で起動し、ab -n3000 http://localhost:3000/fuga/ などにより多量のリクエストを送ると、メモリ使用量が 80MB を超えるまでに増大し、リクエスト完了後もまったく解放されない。foo 内で yield しなくても同様。セッションストレージにデフォルトの CookieStore を使った場合のほか、:active_record_store, :mem_cache_store を使っても同じ。session_options[:expires] の設定も関係無し。
なお、before_filter や after_filter で reset_session した場合は、問題は起こらないようだ。また、rails-2.0.2 環境で発見したが、rails-2.2.2 でも再現する。
状況解析のため、こちらのエントリ(Memory leak profiling with Rails) を参考にメモリ利用状況をプロファイルしてみたところ、around_filter を使った場合は String のインスタンス数が一気に膨れ上がった後、一向に解放されないことがわかった。(before_filter を使った場合は、リクエスト処理完了後、それなりに解放された。)
次に、この String の正体を見るために、上記の MemoryProfiler の :string_debug オプションを使って ObjectSpace 中の String をダンプしてみたところ、session_id などセッションデータと思しき文字列が大量に入っていた。リクエスト終了後は、CgiRequest や CGI::Session などのリクエストやセッションに絡むオブジェクトはすぐに解放されていた事から、何らかの原因でセッション内文字列のみ GC 対象にならない状態になっていると思われる。
さらに、上記の String がどの時点で生成されているのかを追ってみた。
actionpack-2.0.2/lib/action_controller/cgi_process.rb L.150-
private # Delete an old session if it exists then create a new one. def new_session if @session_options == false Hash.new else CGI::Session.new(@cgi, session_options_with_string_keys.merge("new_session" => false)).delete rescue nil CGI::Session.new(@cgi, session_options_with_string_keys.merge("new_session" => true)) end end
どうも上記の CgiRequest#new_session の CGI::Session.new で確保されているようだ。何が原因なのか追いたいが、CGI::Session は ActionPack で拡張されており、どうも切り分けが難しい。いろいろ書き換えながら試してみたところ、オリジナルの CGI::Session 中:
lib/ruby/1.8/cgi/session.rb L.299
ObjectSpace::define_finalizer(self, Session::callback(@dbprot))
ここのファイナライザ定義があることで、around_filter でのみリークするようだ(これを試しに無効にすると、リークしない。謎)。かといって、こんなところを無効にするわけにはいかず.. と、今度は around_filter と before_filter とで、何が変わってきているのか、を追ってみた。
actionpack-2.0.2/lib/action_controller/filters.rb L.709-
def run_before_filters(chain, index, nesting) while chain[index] filter, index = skip_excluded_filters(chain, index) break unless filter # end of call chain reached case filter.type when :before filter.run(self) # invoke before filter index = index.next break if @before_filter_chain_aborted when :around yielded = false filter.call(self) do yielded = true # all remaining before and around filters will be run in this call index = call_filters(chain, index.next, nesting.next) end halt_filter_chain(filter, :did_not_yield) unless yielded break else break # no before or around filters left end end index end
ソースを追う限り、filter 処理は適用順序に並べた配列形式に変換された後、before_filter も around_filter も上記の ActionController::Filters::InstanceMethods#run_before_filters で実際のフィルタ処理がおこなわれているように見える。で、around_filter では、上記の filter.call(self) へ渡しているブロックで index = call_filters している箇所があるが、いろいろ切り分けていってみるに、どうもここが怪しい。around_filter に入れ子になっている filter を再帰的に処理しているようだが、ここの中で CGI::Session.new するとセッション中の String のみが解放されない状態になってしまう.. ということだろうか。
もうひといきな気がするんだが、ちょっとツラくなってきたので、とりあえずここまでで POST しておく。Ruby の GC の問題なのか、Rails の黒魔術のせいなのか、まだ微妙なところだ。とりあえず before_filter にしとけば問題は起こらないのだが、多数のフィルタ処理をサイトの全域で使うようなサービスの場合、個々のコントローラには around_filter をひとつ書いておいて、その filter 内に多数のフィルタメソッドを並べる、というのがラクチンだとおもう。うーむ、悔しいが before_filter で回避してしまおうか.. フィルタ内で、セッション検査をおこなった上で、不正セッションであれば reset_session して仕切り直す、みたいなのって、わりとあるような気がするんだけどなあ.. ハマってる人が見当たらない。
最近のコメント