Ruby の Tempfile で発生した競合状態の原因を探る


2015年 06月 08日

早速だが、コードを見てほしい。ネットワークデータを一旦ローカルの一時ファイルに受け取り、後でゆっくり読み書きしよう、という意図のコードだ。呼び出し元はファイルハンドルを受けとり、シークを含めた読み書きができる、というか、したい。

def create_tempfile(*)
  network_source = ... # 省略
  Tempfile.new("example").binmode.tap do |file|
    IO.copy_stream(network_source, file)
    file.rewind
  end
end

ところが、このコードには問題がある。普段何も問題なく動くのに、時折このメソッドから受け取るファイルに対して操作をしようとしたら IOError が発生することがある。しかもこのエラーは環境や時間、扱うデータによって発生したりしなかったりするし、スタックトレースはなにやら所謂ライブラリ類の奥深く。そもそも問題の原因がここであることを突き止めるにも一苦労があったわけだが、えーっと、現場の苦労話はさておきだ、とにかく「問題の原因はこのコード」、「発生している例外が持つメッセージは “closed stream”」である。

どう直せば解決するか、分かっただろうか?

解答

以下のようにすれば解決する。

def create_tempfile(*)
  network_source = ... # 省略
  Tempfile.new("example").tap do |file|
    file.binmode
    IO.copy_stream(network_source, file)
    file.rewind
  end
end

…ん? 何が変わったのだろう?

一体だれがハンドルを閉じたのか?

例外のメッセージは “closed stream” 、即ちファイルハンドルが既に閉じられていることを示している。では一体だれが閉じたのか。

原因のコードが突き止められていない間に関しては疑う余地はどこにでもあった。どこかの Gem の使い方を間違っている? うっかり close してしまうパターンのコードがあるのか? とはいえそこは解明済。犯人は Tempfile それ自身である。

Tempfile はその性質上「一時的なもの」であり、作成したファイルがいつまでも残るのは好ましいことではない。なのでそうならないよう、 new の時点で自己を閉じるコールバックを ObjectSpace のファイナライザに追加する。こうしておくことで、オブジェクトが利用されなくなったら、GC に連れて行かれるときにファイルハンドルも道連れにできるというわけだ。

なるほど、なるほど。つまりファイナライザが呼び出されてファイルハンドルが回収されてしまったのだね?

いや、ちょっと待ってくれ。問題のメソッドはファイルオブジェクトを呼び出し元に返しているし、呼び出し元はそのオブジェクトを通してファイルにアクセスをしている。だから一時ファイルに対する参照は生きているはずだ。なぜファイナライザが呼び出されているのだ!

Tempfile#binmode は何を返すのか?

解答に示した通り、問題は binmode である。binmode は実際には Tempfile のメソッドではなく、その親クラスの IO が持つメソッドである。公式のドキュメントには以下のように記述されている。

binmode -> self

ストリームをバイナリモードにします。
MSDOS などバイナリモードの存在 する OS でのみ有効です。
そうでない場合このメソッドは何もしません。

参考: http://docs.ruby-lang.org/ja/2.1.0/method/IO/i/binmode.html

実のところこのコードを動かしていたのは Linux 環境なので、ドキュメントの通りであればこれは「何もしない」。単に扱うファイルがテキストではなかったので、「念のため」呼び出されているだけのメソッドなのだが、いったい何が起きているというのだろう。

pry(main)> a = Tempfile.new("example")
=> #<File:/tmp/example20150603-24767-1s14lqk>

pry(main)> b = a.binmode
=> #<File:/tmp/example20150603-24767-1s14lqk>

pry(main)> a == b
=> true

おかしなところはなにもないではないか。

pry(main)> a.class
=> Tempfile

pry(main)> b.class
=> File

…あっ!

そう、 Tempfile#binmode が返すのは self ではない のだ!

当初のコードは binmode の結果に対して tap を行っているので、メソッド全体の返値は binmode の結果である。これは実は Tempfile オブジェクトではなく、その内部の File オブジェクトであった。従って、 Tempfile オブジェクト自体はこのメソッドが終了した時点でどこからも参照されなくなり、「ガーベッジ」として回収を待つ状態となる。そして先に説明した通り、GC によって回収される際、 Tempfile オブジェクトは責任もって自らが作成したファイルを閉じ、削除する。

呼び出し元は Tempfile によって作成された File オブジェクトを通してファイルにアクセスを行っていた。このオブジェクトそのものはひとまずは正当なものであり、アクセスに支障はない。ところが、 GC が該当の Tempfile オブジェクトを回収した に操作しようとすると、既に閉じられているためエラーとなる。

なるほど。エラーが発生したりしなかったりしていたのは、 GC が我々のあずかり知らぬタイミングで動作するせいだった、というわけだ。

関連する話

今回の話は binmode が返す値を勘違いしていたのが原因だったので、前述の変更で問題は解決するが、可能なら RAII パターン、即ち open メソッドにブロックを渡す形式を用い、寿命を明示するのが良い。

def fetch(*)
  network_source = ... # 省略
  Tempfile.open("example") do |file|
    file.binmode
    IO.copy_stream(network_source, file)
    file.rewind
    yield file
  end
end

# 呼び出し元
fetch(...) do |file|
  ... # 一時ファイルを使った何か
end

また、 Tempfile クラス設計という観点であれば、そもそも内部情報を外に出すべきではない。ドキュメントの通り self すなわち Tempfile オブジェクトを返却するか、あるいは敢えて nil を返すべきだ。特に後者の場合、 binmode! のように感嘆符を付けると、見た目もオブジェクトの状態を変更するメソッドに見えて良さそうである。

まとめ

早く binmode なんか必要ない世界になればいいのに。

…失礼。Tempfile#binmode が返す値には罠がある。取り扱いには注意しよう。