はじめに
libuiは、Windows、macOS、Linuxといった主要の3つのオペレーティングシステムに対応するGUIライブラリです。内部的には、ネイティブなAPIを呼び出す異なる3つのライブラリがあり、それを一つの ui.h にまとめることで、どのOSでも類似のUIを実現しています。また、FFIで他言語から用意に利用できます。現在はやや開発が緩やかですが、類似のライブラリは少なく、現在も独自の価値を保っています。
libui のバインディング
さて、私は、libui の Ruby バインディングと Crystal バインディングを作成しています。
その過程で、libuiとガベージコレクションを組み合わせるのが難しいと感じるようになりました。
コントロールやコールバック関数が消える問題
libui のRubyバインディングやCrystalバインディングを作成することはそれほど難しくありません。関数のシグネチャーを確認して、一致する低レベルのバインディングを書く作業は機械的に行うことができます。
しかし、それらの低レベルのAPIを呼び出して簡単なアプリを作成すると、一定の確率で次のような現象が発生します
- コントロールが消えてメモリアクセス違反が発生する
- コールバックが消えてメモリアクセス違反が発生する
RubyもCrystalも、ガベージコレクション(GC)を利用する言語なので、利用していないと判断されたメモリが回収されます。そのため、GUIのメインループで将来利用されるはずの、ポインタやコールバック関数が、CGによって誤って解放されてしまうのです。
GC言語では、メモリ解放のタイミングは参照によって間接的にコントロールします。
Rubyでは、コールバック関数は、問答無用で専用の配列に保存します。事実上のメモリーリークとなりますが、コールバック関数は有限個であるため、実用上は問題になりません。
Crystalの場合はもう少し複雑な管理をしています。それぞれのコールバック関数は、関連するコントロールのインスタンスに紐付けられています。たとえば、ボタンを押したときに発火するコールバック関数は、ボタンが所有しています。また、コントロール自体の入れ子関係は、所有ツリーとして再現しています。例えば、WindowがBoxを持ち、BoxはLabelとButtonを保持するという構造です。
このように所有ツリーを利用することで、GCによる誤回収の問題を大幅に減らすことができます。
ところで、メインループ上でコントロールが後から参照されることがあるのに、なぜCrystalのGCはポインタを回収してしまうのでしょうか?
この点は筆者の理解が浅くはっきりわかりませんが、クロージャーをBox化する段階で、メモリの追跡が難しくなっている可能性があります。
libui のメモリ管理のルール
libui はC言語のライブラリであり、ユーザーがメモリーを管理する設計です。
しかし、実際には「親コントロールが解放されると、子コントロールのメモリも解放される」という仕組みが導入されています。親コントロールになれるのは Window, Box, Grid, Group, Tab, Form です。
これらを destory
すると、まず子コントロールが解放され、それから親自身が解放されます。よって実際の運用では、Windowをdestroyすることで、子コントロールをまとめて開放することが多いでしょう。
問題は、Crystal側では、このようなネイティブライブラリ内での解放を検知できないという点です。メモリ解放直後であればNULLチェックで推測も可能ですが、これは不確実です。(libui はメモリを解放する前に親コントロールのポインタを null に設定していたはずで、これを見に行くことで不確実な推測ができます)
Windowの解放は自動的に行われる場合がありまます。Windowのタイトルバーの [x] ボタンがクリックされた場合、uiWindowOnClosing
によりコールバック関数が発火し、戻り値がtrueの場合はWindowのdestoryが自動発火します。
それに対して、メニューバーのQuitから発火する uiOnShouldQuit
は、アプリケーションの終了を表現しているので、windowに対する destroy は自動発火しません。ユーザーが自分自身でWindowをdestoryし、uiQuit を呼び出す必要があります。
libui によるメモリ解放忘れチェック機構
libui にはメモリ解放忘れを検出する仕組みが組み込まれています。
とても有用な機能なのですが、GC言語との相性はしばしばよくありません。GCではメモリの解放タイミングが不定であり、チェックのタイミングですべてのメモリが解放されているか保証できないからです。したがって、CGのfinalize
にフックして、解放を行うような実装は避けたほうがよいでしょう。
Table の解放手順
Tableは Model-View アーキテクチャに基づき、TableModel と Table が分離しています。TableModelは、そのモデルを使用するすべてのTableが破棄されたあとにはじめて解放できます。そのため、解放手順は以下のようになります。
- 親コントロールからTableを削除する
- 明示的にTableをdestroyする
- 最後にTableModelをdestoryする
Area の解放手順
AreaはTableとは異なり、単純にコントロールをdestroyするだけで大丈夫です。
MultilineEntry の解放手順
詳細な原因調査が進んでいないのですが、macOSでは、Tableと同じように親コントロールから外して個別にdestroyしないと不具合が発生するケースがあるようです。
まとめ
libui-ng を利用する際には、メモリの管理、とくに解放において多くの注意点があります。
CrystalやRubyのようなガベージコレクションを利用する言語では、通常メモリの心配は必要ありません。また、C言語のバインディングであっても、finalize
など解放用のコールバック関数を利用することで、メモリの手動管理が不要になることが多いです。
しかし、GUIライブラリのようにインタラクティブな操作があり、時間とタイミングの同期が重要なライブラリでは、あまりGCに頼らずに、適切な時期に手動でメモリの解放を行わなければならないケースがあるということを学びました。
そういったケースでは、RubyやCrystalの場合は、RAII の考え方に基づき、ブロックを利用するAPIを用紙することが多いです。これで半分以上のケースに対応できます。
それだけでは難しいケースも存在している気がしますが、私も勉強中で試行錯誤しています。
この記事は以上です。