Gauche:Gtkとメモリ管理
クロージャを介した循環参照問題
Shiro (2002/11/27 17:50:57 PST): さかいさんとこ で指摘された問題なのだが、現在のGauche-gtkの実装ではメモリリークが生じる場合がある。
具体的には次のようなコード:
(let ((button (gtk-button-new))) (g-signal-connect button "clicked" (lambda (w) (action button)))
で、buttonが指すオブジェクトと lambdaで作られるクロージャが解放されない可能性がある。
コールバックのメモリ管理
問題を整理するために、Gauche-gtkのメモリ管理をまとめておく。
(a) SchemeからGtkへのポインタ:
- Gtkオブジェクト本体はリファレンスカウンティングで管理される。
- SchemeオブジェクトはGtkオブジェクトをポイントし、 その際にGtkオブジェクトのリファレンスカウンタをインクリメントする。
- SchemeオブジェクトがGCされる時に、finalizerの中から それが指しているGtkオブジェクトのリファレンスカウンタをデクリメントする。
(b) GtkからSchemeへのポインタ:
- Gtkにコールバック手続きとしてクロージャを渡す場合、Gtkは Schemeオブジェクトへのポインタを保持することになる。
- Gtk内部のポインタはSchemeのGCからは見えない。従って、 クロージャを指すポインタがGtkからのもののみの場合、 クロージャが誤ってGCされてしまう。
- それを防ぐために、Gtkにクロージャが渡される際に、 Scheme側のテーブルにクロージャを登録する (b')。 GtkがそのSchemeポインタを必要としなくなった時に呼ばれるコールバックにより、 Scheme側のテーブルからもSchemeポインタを削除する。 そのコールバックが呼ばれるのは、Schemeポインタを保持しているGtkオブジェクトが 削除される時か、Gtkオブジェクトが別のポインタを保持するようになる時である。
さてここで、問題のコードに戻る。
- g-signal-connectに渡されるクロージャは、gtk-button-newで作られた Gtkオブジェクトへのポインタをその環境中に含む。(c) この環境はクロージャがGCされない限り解放されない。
- 一方、gtk-button-newで作られたGtkオブジェクトは、 クロージャの環境が解放されない限り、リファレンスカウントが0になることはない。
- Gtkオブジェクトのリファレンスカウントが0にならない限り、 クロージャは解放されない。
図解すると次の通り。
Scheme World . GTk World . . ScmGObject . /--->+------------+ . closed env | | SCM_HEADER | . GObject | |------------| (a) . +----------+ +-------+ | | gobject ---------------->| | /->| ----/ +------------+ . : : | | | . (b) | | | | | /----------- data | | | | | . + ---------+ | +-------+ | . : | | . : | ScmClosure | . : destroyNotify | +------------+ | . : | | SCM_HEADER |<-+-/ . : | (c) |------------| | . : \--------------------- env | | . : +------------+ | . : |(b') : | . : protected | : callback |<_................ table | +------------+ | . | | | . : : | . | -----/ . : : . : : .
現時点での(不完全な)対応
Gtkオブジェクトへの参照が環境に閉じこまれないように気をつかってやる ことは出来る:
(let () (define (button-action w) (action w)) (let ((button (gtk-button-new))) (g-signal-connect button "clicked" button-action)) )
が、これではクロージャが自由に使えないのであまり現実的ではない。
ただ、頻繁にwidgetのdestroy/constructを繰り返すプログラムならともかく、 一度widget hierarchyを構築してそれを最後まで使い続けるような プログラムなら、循環参照は問題にならないとも言える。
将来、コンパイラをもっと賢くして、 必要以上に環境を閉じ込まないようにすれば、 以下のコードは循環参照を引き起こさないようにできる。
(let ((button (gtk-button-new))) (g-signal-connect button "clicked" (lambda (w) (action w)))
どちらかのGCにフックが必要
問題の構造がGCされる条件は、
- (1) Scheme側の構造 (ScmGObject, コールバック、環境) に他のSchemeオブジェクトからの参照が無い (protected callback tableからの参照は除く)
- (2) Gtk側の構造 (GObject)に他のGtk世界からの参照が無い
という両方の状態を満たすことである。 (ScmGObject⇔GObjectが1対1対応であることは保証されている)
ところで、現状では(1)を判断するにはScheme側GCでmark phaseを通すしか 方法が無い。一方、(2)を判断するにはGtk側でrefcountを0にするしかない。 いずれも、片方の条件が判明した時点で構造は回収に回されてしまうので、 その時点でもう片方が満たされていないため「待った」をかけることが出来ない。
現在の実装は回収に回されることを防ぐために、相互参照によって 条件(1)も(2)も決して満たされないようにしているわけだ。
「待った」をかけるためには、少なくともどちらかのGCメカニズムに手を加える必要がある。
Scheme側に手を加えるとすれば、mark phaseが終った時点で回収予定の ScmGObjectから指されているGObjectのrefcountを調べ、それが1ならば そのまま回収、しかし2以上ならばScheme側構造を再マークする、という パスが必要になる。 この再マークによっていもづる式に救済されるオブジェクトがあるだろうから、 マークは全部やりなおさなくちゃならない。
Gtk側に手を加えるとすれば、ref/unrefをフックして、 「ScmGObjectから指されていて、refcountが2以上である」GObjectを テーブルに登録しておき、Scheme側のGCの時にスキャンされるようにしておく。
待てよ、こういう方法もあるな。
- ScmGObjectが指しているGObjectと、それに渡されたScheme pointerの テーブルを持っておく。
- Scheme側のGCのmark phaseが始まる直前にテーブルをスキャンし、 GObjectのrefcountが2以上あった場合は対応するScheme pointerをroot set に加える。
- sweep phaseでScmGObjectがfinalizeされたら対応するGObjectを テーブルから除き、unrefする。この時点でGObjectも回収されるはず。
これならScheme側GCの少しの改造で済むか?
GObjectからのみ参照されるScmGObject
GtkWidgetの派生クラスをSchemeから定義できるようにして 少しいじっていたら別の問題に気づいた。
ScmGObjectとGObjectの1対1対応を保証するため、 GObjectを指すScmGObjectが作成されると、GObjectのqdataに作成された ScmGObjectへのポインタを持つようにしている。
ところでGObjectのqdataはSchemeのGCからは見えない。 従って、
- Scheme側からScmGObjectを指すポインタが無い
- しかし、GObjectの方は他のGObjectからも指されており、refcountが2以上ある
という場合に、ScmGObjectが回収されてしまうのだ。 qdataにあったポインタはScmGObjectのファイナライザにより削除される。
派生クラスを作らない場合はScheme側オブジェクトが 回収されても障害は目立たなかった。問題のGObjectを Scheme側で取り出す際に自動的に新たなScmGObjectが作られていたからだ。 (それでも、Scheme側のみに保存されているプロパティ等が失われていたのだが)。
しかし派生クラスを作っていると、サブクラスのスロットはScheme側で アロケートされているので、Scheme側オブジェクトが回収されてしまっては困る。
GMemVTableで解決なるか…と思ったが
どっかにフックかけられないかとGtkとGlibをつらつら眺めてたら、 Glibはメモリアロケータをすげ替えられることを今更ながらに知った。 g_mem_set_vtable()でユーザが用意したmalloc等を呼ぶように出来る。 ならこいつにGC_malloc等を渡してやれば、GtkからSchemeオブジェクトへの ポインタがScheme GCに見えるようになるからイケるんではないか。
と、喜び勇んでいろいろやってみたのだが、うーん、うまくいかない。
- 全てをBoehm GCに任せると、ごみになったGObjectは黙って回収されてしまい、 クリーンアップが行われない。クリーンアップはg_object_unref()して _ref_countが0になった時点で呼ばれるから。
- それならばとGObjectにファイナライザをつけてその中でg_object_unref() するようにしてみる。 しかし何故か生きてるはずのGObjectのファイナライザが呼ばれてる。 むむむ…なんと、Glibはよく使われるサイズのオブジェクトに関してはチャンクを g_mallocで確保しといて内部で小分けにして使っているのだった。 これでは特定のGObject毎にファイナライザを呼ぶことは出来ない。
- それならば、ScmGObjectのファイナライザでGObjectをunrefしたらどうか。 Gtk側からの参照が無くなる時は必ずunrefが呼ばれるはずだから、 ScmGObjectが消える時が最後のunrefになるはず。 と、やってみたがScmGObjectが回収されない。どうも、 Glib内でchunkで管理されているオブジェクト内にポインタが残ってしまうようだ。 (glibを-DENABLE_GC_FRIENDLYでコンパイルすればオブジェクトをfree listに 戻すときにポインタをクリアしてくれるようだが、Gauche-gtkのために glib再コンパイルってのは嫌だなあ)
他のシステムではどうやってるのだ?
stklos
うーむ、GtkObjectは明示的にScheme側からdestroyされない限り 回収されないように見えるのだが…それにSchemeがGtkObjectへのポインタを 得た時にref/sinkしてないので、gtk側に既にあるGtkObjectを Scheme側で取り出して、その後でgtk側でunrefされるとまずいんじゃないのかなあ。
また、GtkObjectのdataにSchemeへのポインタをしまっているが、 別にprotectしている様子は無い。いいのかな。 glibのmallocをGC_mallocに置き換えているわけでもなさそうだし…
bigloo
Gtkへのバインディングはbigloo-lib中にある。
g-object-ref/unrefをSchemeレベルにエクスポートしている。 Scheme側で責任持ってref/unrefしろってことかいな。 Gtk側に渡したポインタをprotectするコードも見当たらない。 mallocは置き換えていないようだし。うーむ、謎。
なお、Biglooはデフォルトではfinalizerを使っていない。
guile-gtk
guileはモジュールがたくさんあってあちこちに分散しててよくわからない。 http://www.nongnu.org/guile-gtk/ が今のメインサイトなのかな。 あんまり開発が活発ではないようだけれど…
- guile-gobject 0.4.0 : Gtk+ 2.0対応のコードはこれかしらん。 Scheme側でGObjectを作って、GCされたらunrefする、またGObjectからSchemeへの マッピングはqdataを使う、という点はGaucheと同じ。 循環参照に対処するコードは見付けられず。GClosure作成の時に protectして、そのfinalizerでunprotectしてるからうちと同じだ。 また、Scheme側GCがGObjectのqdataをスキャンするコードが見当たらないんだが、 これだとScheme側の参照が全て落ちた時にScheme objectが消える問題には 対応しないってことかな。
- 既に議論されていた。 GC and GTK+ から始まるスレッド参照。循環参照の問題は これ。 Guile-gtkではGCが 内部参照と外部参照を分けて数えるとある。
- 確かに。guile-gtk-1.2.0.31のgtkobj_marker_hook (guile-gtk.c)でやってる。 Guileはタイプ毎のmarkerを指定できるからこれが可能なわけね。
PyGTK
http://www.daa.com.au/~james/pygtk/
普通にdestroy callbackでunref。Gtk側に渡したPython objectも同じ。 python本体もrefcountingだし、循環参照は仕方ないってことか。 あれ、python 2.xになってmark-sweep GCも入ったんだっけ。 そこらへんをどうしてるかはわからない。
Gtk#
http://gtk-sharp.sourceforge.net/
Java-GNOME
http://java-gnome.sourceforge.net/
Hacking Boehm GC
Boehm GCもアプリケーション側がmark procedureを指定できるAPIが一応存在する。 一応というのは、アプリケーションレイヤではなく言語のランタイムルーチン等、 より低レベルのレイヤから使われることを想定したAPIだから。 mark procedure中でGCの内部情報にアクセスする必要があるし。 基本的には、「もともとbrute forceなconservertive pointer findingでも 動くけれど、もう少し賢くしてGCの効率を上げたい」というチューニング目的で 用意された感じだ。
従って、guile-gtkの方法を直接載せられるかどうかには疑問がある。 mark procedureはあまり多くのメモリページに触らないことを期待されているし、 incremental collectionのためにあまり長い作業をしないことも期待されている。
ま、とりあえず、gcのソースを読みながらごにょごにょいじってみよう。
Boehm GCではpointer scanningのポリシーが異なるアロケータをいくつも持てる。 ソース中では各アロケータを "kind" と称している。GC_malloc_atomicとか GC_malloc_uncollectableとかは別々のkindとして実装されているわけだ。 また、オブジェクト中のポインタの在処をビットマップで指定できるtypedとか、 オブジェクトクラス毎にmark procedureを持てるgcj用のkindなんかが オプションで用意されている。
各kindの情報はstruct obj_kindで記述される (gc/include/private/gc_priv.h)。 MAXOBJKINDS分のstruct obj_kindのアレイがグローバルに用意されており、 それ以上のkindを持つにはリコンパイルが必要。このことからもkindは 無闇に増やすことは想定されていないことがわかる。
アロケータそのものは全てのkindで共通。特に、小さなオブジェクトは ページ単位で確保されたプールからfreelistを使ってアロケートされるので、 そのfreelist arrayへのポインタはkindを作る側で用意しなければならない。
そのkindのオブジェクトをどうマークしたら良いかという情報は obj_kind.ok_descriptorにエンコードされる。(gc/include/gc_mark.h)
- オブジェクトの長さ+固定オフセットの分はスキャン
- スキャンすべきワードをビットマップで指定
- ユーザ定義のmark procedureを呼ぶ
- オブジェクト毎に指定オフセット位置にマークディスクリプタを保存
mark procedureを呼ぶ場合、procedure自体は配列GC_mark_procs[]に登録 されていなければならない。その配列のインデックスでprocedureを指定する。
Tag: GC