Hash を Marshal.dump 中に要素追加すると例外が発生する

Ruby の trunk (1.9.2 branch も同じく)で最近みかけるようになった現象について。
ruby-dev にでも post しようかと思いつつ要点がまとまらないので一度ここに書いておこうと。

以前から Hash の each 等のイテレータ系のメソッドの呼び出し中にその Hash オブジェクトに要素を追加しようとすると例外が発生します。わたしの記憶ではさらに昔*1では例外にはならないけど変な順番でブロックが呼び出されたりSEGVしたりしてたと思います。

> a = { :a  => 1 }
=> {:a=>1}
> a.each{ a[:b] = 2 }
RuntimeError: can't add a new key into hash during iteration

上の例は Hash#each のブロックの中で要素追加していますが、Thread を使ってる時には Hash#each を呼んでいるスレッドとは別のスレッドで要素追加する場合も同様です。

> a = { :a  => 1 }
=> {:a=>1}
>> thr = Thread.start{ a.each{ sleep 100 } }
=> #<Thread:0x0000010097a8c8 run>
>> a[:b] = 2
RuntimeError: can't add a new key into hash during iteration

これは以前からの挙動で、まあちゃんと Mutex なり MonitorMixin で排他処理しましょうということで良いのですが、今の trunk と 1.9.2-preview3 ではさらに Hash を Marshal.dump でシリアライズ中の要素追加も同じ例外が発生するようになっています。

厳密には昔から Marshal.dump も内部的に rb_hash_foreach() 関数を利用していて潜在的にはこの例外が発生し得る処理をしていたと思われますが、どうも 1.8 系や 1.9.1, 1.9.2-preview1 までは Marshal.dump 中にスレッドの切り替えが起きない、または起きにくいので結果として Hash#[]= は待たされて例外発生には遭遇しなくて済んでいたみたいです。

=> {}
>> 1000000.times do |i| a[i] = i.to_f end
=> 1000000
>> thr = Thread.start{ Marshal.dump(a).bytesize }
=> #<Thread:0x000001013fd458 run>
>>  a[-1] = -1.0
RuntimeError: can't add a new key into hash during iteration

おそらくインタプリタとしては不具合ではなく、ちゃんとスレッド切り替えが起きるように改善されたのであって、副作用として遮蔽されていた潜在的な問題が表出してきただけと考えるべきなんだろうなぁと思うのですが、これがなかなかやっかいです。Marshal.dump も排他処理すればいいだろと言われればそれはその通りなのですが、dRuby で Hash を返すメソッドを呼ぶのと組み合わせると、dRuby が通信用に Marshal.dump を呼ぶので制御できない(ロックできない)ところで dump されてしまうからです。

とても恣意的ですが以下のようなサンプルで server.rb を起動してから表示される uriコマンドライン引数にして client1.rb client2.rb を起動すると client1.rb が Hash#[]= の例外発生で止まります(タイミングが合えば)。

(server.rb)

require "drb"
require "monitor"
class Table
  include MonitorMixin
  def initialize
    @table = {}
  end
  def table
    synchronize do
      @table
    end
  end
  def []=(key , value)
    synchronize do
      @table[key] = value
    end
  end
end
table = Table.new
server = DRb.start_service("druby://:0", table)
puts server.uri
DRb.thread.join

(client1.rb)

require "drb"
uri, = ARGV
remote = DRbObject.new_with_uri(uri)
100000.times do |i|
  remote[i] = i.to_f
end

(client2.rb)

require "drb"
uri, = ARGV
remote = DRbObject.new_with_uri(uri)
100000.times do
  puts remote.table.size
end

そこでどう対処するのがいいのかというのを悩んでいるわけです。一応考えているのは……

  • Hash を含むオブジェクトの Marshal.dump をカスタマイズしてインスタンス変数の Hash を dup してそれをシリアライズさせる
    • ちょっとパフォーマンスが悪くなる。あとネストした Hash には無力。多分うちのアプリケーションではこれでは不十分。
  • Hash#[]= を呼んでいる所で rescue して上記の例外が発生したら retry
    • え、全部の箇所で? さすがにちょっと……あと例外が RuntimeError なので message でチェックしないといけないのがあんまりといえばあんまり
  • Hash#[]= を再定義して上記の例外発生時 retry する
    • うちのアプリケーションではこれでいいかも。Hash#each の中で IO 待ちや sleep するようなことをしてて例外発生を期待しているようなところがあるとまずいけど、そんなのあるかなぁ
    • 例外が RuntimeError なので message でチェックしないといけないのがあんまりといえばあんまり
  • インタプリタにパッチを当ててしまって同じことをする。例外発生するかわりに rb_thread_yield rb_thread_schedule すればいいんじゃないかな
  • あと例外は専用の例外がいいなー。仕様変更になりますが

ruby 本体で何かしらケアしてくれるのがいいとは思うのですが、じゃあどうなってくれればいいのかと言われるとなんとも……。あきらかに 1.9.2 ではもうどうにもならない類いの話なので、どうなるのがあるべき姿なのかわかれば手元の環境での対処策も目処がつくかなぁと。

[追記] この日記を書いてみたら考えが整理されて Hash の dump 中は新しい要素の追加が待たされるというのがベストかなと思いました。イテレータを全て待つと each の中でブロックしているとダメだけど dump だけならいずれ止まるはずなのであまり問題ないと思います。

*1:多分 1.6 系とか?