IL2CPPの内部:ガベージコレクタの統合

このシリーズのすべての記事と同様に、この記事は、将来変更される可能性があり、おそらく変更されるであろう実装の詳細を扱っている。この投稿では、ガベージ・コレクタと通信するためにランタイム・コードが使用する内部APIを特に見ていく。これらのAPIは一般にはサポートされていないので、実際のプロジェクトのコードから呼び出そうとしてはならない。しかし、これは内部についての記事なので、掘り下げていこう。
一般的なガベージコレクションのテクニックについては、この記事では触れない。GCとは、オブジェクト参照の有向グラフを展開するアルゴリズムだと考えればよい。子オブジェクトが親オブジェクトに(ネイティブコードではポインタを介して)使用されている場合、グラフは次のようになる:

GCはプロセスのメモリをスキャンしながら、親を持たないオブジェクトを探す。もし見つかれば、そのオブジェクトのメモリーを他の何かに再利用することができる。
もちろん、ほとんどのオブジェクトは何らかの親を持つので、GCはどのオブジェクトが重要な親なのかを本当に知る必要がある。これらのオブジェクトは、あなたのプログラムで実際に使用されるオブジェクトだと考えたい。GCの用語では、これらを「ルーツ」と呼ぶ。以下はルートのない親の例である。

この場合、Parent 2はルートを持たないので、GCはParent 2とChild 2のメモリを再利用できる。しかし、親1と子1はルートを持っているので、GCはそれらのメモリを再利用できない。プログラムはまだ何かに使っている。
.NETでは、3種類のルーツがある:
- マネージドコードを実行するスレッドのスタック上のローカル変数
- 静的変数
- GCHandleオブジェクト
IL2CPPがガベージ・コレクタと、これら3種類のルートすべてについてどのように通信するか見てみよう。
この投稿では、OSX上でUnity 5.1.0p1を使い、iOSプラットフォーム向けにビルドしています。これにより、Xcodeを使ってIL2CPPがガベージ・コレクタとどのように相互作用するかを見ることができる。このシリーズの他の記事と同様、1つのスクリプトを使ったサンプル・プロジェクトを使うことにする:
using System;
using System.Runtime.InteropServices;
using System.Threading;
using UnityEngine;
public class AnyClass {}
public class HelloWorld : MonoBehaviour {
private static AnyClass staticAnyClass = new AnyClass();
void Start () {
var thread = new Thread(AnotherThread);
thread.Start();
thread.Join();
var anyClassForGCHandle = new AnyClass();
var gcHandle = GCHandle.Alloc(anyClassForGCHandle);
}
private static void AnotherThread() {
var anyClassLocal = new AnyClass();
}
}ビルド設定ダイアログで "開発ビルド "を有効にし、"Run in Xcode as "オプションを "Debug "に設定しました。生成されたXcodeプロジェクトで、まず "Start_m "という文字列を検索する。HelloWorld_Start_m3という名前のHelloWorldクラスにStartメソッドのコードが生成されているはずだ。
HelloWorld_Start_m3関数のThread_Start_m9が呼び出される行にブレークポイントを追加する。このメソッドは新しい管理対象スレッドを作成するので、そのスレッドがルートとしてGCに追加されることを期待する。Unityに同梱されているlibil2cppのヘッダファイルを調べれば、この現象がどこで起きているかがわかります。Unityのインストールで、Contents/Frameworks/il2cpp/libil2cpp/gc/gc-internal.hファイルを開きます。このファイルには il2cpp_gc_ というプレフィックスが付いたメソッドがいくつかあり、 libil2cpp ランタイムとガベージコレクタの間の API の一部として機能する。これは公開APIではないので、実際のプロジェクトのコードからこれらのメソッドを呼び出さないように注意してください。これらは予告なしに変更または廃止されることがあります。
Xcodeで、Debug > Breakpoints > Create Symbolic Breakpointを使って、il2cpp_gc_register_thread関数にブレークポイントを作ってみよう。

Xcodeでプロジェクトを実行すると、ブレークポイントがほとんどすぐにヒットすることに気づくだろう。libil2cppランタイムのスタティック・ライブラリでビルドされているため、ソース・コードを見ることはできないが、コール・スタックから、このスレッドがInitializeScriptingBackendメソッドで生成されていることがわかる。

選手が内部で使用する各マネージド・スレッドを作成するため、実際にはこのブレークポイントが何度もヒットすることになる。今のところ、Xcodeでこのブレークポイントを無効にして、プロジェクトを続行することができます。先ほどHelloWorld_Start_m3メソッドで設定したブレークポイントにぶつかるはずだ。
今、スクリプト・コードによって作成されたマネージド・スレッドを開始しようとしているところなので、もう一度il2cpp_gc_register_threadでブレークポイントを有効にしてください。このブレークポイントを押したとき、最初のスレッドは作成したスレッドに参加するのを待っているが、作成したスレッドのコール・スタックは、スレッドを開始したところであることを示している:

スレッドがガベージコレクタに登録されると、GCはそのスレッドのローカルスタック上のすべてのオブジェクトをルーツとして扱う。そのスレッドで実行するメソッド(HelloWorld_AnotherThread_m4)の生成コードを見てみよう:
AnyClass_t1 * L_0 = (AnyClass_t1 *)il2cpp_codegen_object_new (AnyClass_t1_il2cpp_TypeInfo_var);
AnyClass__ctor_m0(L_0, /*hidden argument*/NULL);
V_0 = L_0;GCがルートとして扱わなければならないローカル変数L_0が1つ見える。このスレッドの(短い)寿命の間、AnyClass オブジェクトのこのインスタンスとそれが参照する他のオブジェクトは、ガベージ・コレクタによって再使用できません。スタック上で定義された変数は、GCルーツの最も一般的な種類である。プログラム内のほとんどのオブジェクトは、管理対象スレッド上で実行されるメソッド内のローカル変数から始まるからだ。
スレッドが終了すると、 il2cpp_gc_unregister_thread 関数が呼び出され、 スレッドスタック上のオブジェクトをルートとして扱うのをやめるように GC に指示する。その後、GC は L_0 によってネイティブ・コードで表される AnyClass オブジェクトのメモリを再利用します。
しかし、スレッド呼び出しスタック上に存在しない変数もある。これらは静的変数であり、ガベージ・コレクターによってルーツとして処理される必要がある。
IL2CPPがクラスのネイティブ表現をレイアウトするとき、クラス内のインスタンス・フィールドとは異なるC++構造で、すべての静的フィールドをグループ化します。Xcodeでは、HelloWorld_t2クラスの定義に飛ぶことができる:
struct HelloWorld_t2 : public MonoBehaviour_t3
{
};
struct HelloWorld_t2_StaticFields{
// AnyClass HelloWorld::staticAnyClass
AnyClass_t1 * ___staticAnyClass_2;
};IL2CPPは、C++のstaticキーワードを使用しないことに注意してください。GCと適切に通信するために、staticフィールドのレイアウトと割り当てを制御する必要があるからです。実行時に型が最初に使われるとき、libil2cppのコードは型を初期化する。この初期化の一部として、HelloWorld_t2_StaticFields構造体のメモリ確保がある。このメモリは、il2cpp_gc_alloc_fixed(gc-internal.hファイルにもある)というGCへの特別な呼び出しで割り当てられる。
この呼び出しは、割り当てられたメモリーをルートとして扱うようガベージコレクタに通知し、GCはプロセスの存続期間中、これを忠実に実行する。Xcodeでil2cpp_gc_alloc_fixed関数にブレークポイントを設定することは可能だが、(この単純なプロジェクトでも)かなり頻繁に呼び出されるので、ブレークポイントはあまり役に立たない。
静的変数は使いたくないが、ガベージコレクタがオブジェクトのメモリを再利用するタイミングをもう少しコントロールしたいとする。これは通常、マネージドオブジェクトへのポインターをマネージドコードからネイティブコードに渡す必要がある場合に役立つ。ネイティブ・コードがそのオブジェクトのオーナーシップを取る場合、ガベージ・コレクターに、ネイティブ・コードがオブジェクト・グラフのルートになったことを伝える必要がある。これは、GCHandleと呼ばれる特別なマネージド・オブジェクトを使うことで機能する。
GCHandleの作成は、指定された管理オブジェクトがGCにおいてルートとして扱われるべきであり、そのオブジェクトとそれが参照するオブジェクトが再利用されないように、ランタイムコードに通知する。IL2CPPでは、Contents/Frameworks/il2cpp/libil2cpp/gc/GCHandle.hファイルに、これを実現するための低レベルAPIを見ることができる。繰り返しますが、これは公開APIではありません。GCHandle::New関数にブレークポイントを置いてみよう。プロジェクトを続行させると、このようなコールスタックが表示されるはずだ:

生成されたStartメソッドのコードはGCHandle_Alloc_m11を呼び出しており、最終的にGCHandleを作成し、新しいルート・オブジェクトができたことをガベージ・コレクターに通知していることに注目してほしい。
IL2CPPランタイムがガベージ・コレクタとどのように相互作用し、どのオブジェクトが保存すべきルーツであるかを知らせるかを見るために、いくつかの内部APIメソッドを見てきた。IL2CPPがどのガベージコレクタを使うかについては、まったく話していないことに注意してほしい。現在はBoehm-Demers-WeiserGCを使用しているが、クリーンなインターフェイスの後ろにガベージコレクタを分離するよう努力してきた。現在、オープンソースのCoreCLRガベージコレクタの統合を研究する計画がある。この統合の確実な出荷日はまだ決まっていませんが、私たちの公開ロードマップで最新情報をご確認ください。
いつものように、我々はIL2CPPのGC統合の表面を掻いたに過ぎない。IL2CPPとGCがどのように相互作用しているのか、もっと調べてみることをお勧めする。あなたの見識も共有してほしい。
次回は、IL2CPPコードをどのようにテストするかについて見て、IL2CPP内部シリーズのまとめをする。
