RubyCocoaアプリでTableViewのスクロール時に落ちることがある問題の調査
もうずいぶん前から調べてはいたんだけど、一定の結論に達したので記録。
調査結果
- 現象としては、GCされたRubyのVALUEを参照していることが原因。
- 落ちる場所は一定で、ovmix_ffi_closure()のrb_obj_kind_of()。
- RubyCocoaでObjective-CのidとRubyのVALUEの対応を保持しているキャッシュが、RubyオブジェクトがGCされても残っているケースがある。
- GCされた場合、そのオブジェクトのファイナライザでキャッシュから除外しているが遅延したときに問題が発生する可能性がある。
- キャッシュを無効にすると問題は発生しなくなる。
落ちる場所はOverrideMixin.mのこのあたり。
147 if (!NIL_P(arg) 148 && rb_obj_is_kind_of(arg, objid_s_class()) == Qtrue // <= crash!! 149 && !OBJCID_DATA_PTR(arg)->retained) { 150 OVMIX_LOG("retaining %p", OBJCID_ID(arg)); 151 [OBJCID_ID(arg) retain]; 152 OBJCID_DATA_PTR(arg)->retained = YES; 153 OBJCID_DATA_PTR(arg)->can_be_released = YES; 154 }
RubyCocoaではObjective-CのオブジェクトをRubyに持ってきたときに、OSX::ObjCClassNameなクラスのRubyオブジェクトとして扱うんだけど、それと合わせてObjective-Cのidと生成したRubyオブジェクトのVALUEをペアにしてst_tableにキャッシュとして保存するようになっている(ocda_conv.m)。これは主にパフォーマンスのためだと思うんだけど、これによって同じObjective-Cのオブジェクト(id)には同じRubyのオブジェクト(VALUE)を持たせることができている。
RubyCocoaのObjective-C → Rubyのオブジェクト変換の流れ。
- idに対応するVALUEがキャッシュに保存されているかを確認。あればそのVALUEを使う。
- なければ新しいVALUEを生成する。
- id -> VALUE の対応をst_tableにキャッシュとして保存する。
で、このst_tableに入れている値なんだけど、VALUEがGCされたときにはファイナライザでst_tableから削除されるようになっている。オブジェクト本体のGCとファイナライザの実行にタイムラグがあると、キャッシュからGC済みのVALUEをとってきてしまい、もちろんプロセスは落ちる。
CRuby(1.8.7)のgc.cを読んだ感じだと、ファイナライザの実行は遅延されることがあるのでこれはありうる状況だと思う。遅延される条件に該当するかどうかがちょっと自身なし。
NSTableViewのdelegateなんかはスクロール時などにセルごとにdelegateが呼び出されるので、NSTableViewやNSTableColumnは(表示されている)セルの数だけ1 RunLoopでRuby界に出現するのでこの問題にハマリやすいということなんだと思う。GC.stress下でテーブルをスクロールさせると再現しやすい。
ということでこの推測に基づいて、今後対応していくことになる。
今後の予定
で、対策としてどうするの?ってことなんだけど
あたりですかねえ。
前者は、
- 実装は簡単。
- パフォーマンスが落ちる可能性あり。どのくらいかはやってみないとわからない。試してる範囲ではわからない程度に感じる。
- 同じObjective-Cで別のRubyオブジェクトてときにobj#==otherやobj.eql?(other)が成立するようにしたげたほうがいいかな。
後者は、
- パフォーマンスは現状とあまり変わらないと思う。GC頻度をどう制御するかにもよる。
- 「てきとう」てのが難しいよね。。
という感じです。時間かけてもしゃーないのでとりあえず前者ができた時点で次のバージョン1.1.0をリリースします。