以下のように 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 して仕切り直す、みたいなのって、わりとあるような気がするんだけどなあ.. ハマってる人が見当たらない。
Continue reading »
最近のコメント