16 4月 2025

循環参照とメモリリーク:C# コードを C++ へ移植する方法

コードが正常に変換・コンパイルされた後でも、特にメモリ管理に関連するランタイムの問題に遭遇することがよくある。これらは、ガベージコレクタを持つ C# 環境では典型的ではない問題である。この記事では、循環参照や時期尚早なオブジェクト削除といった特定のメモリ管理問題に焦点を当て、我々のアプローチがこれらの問題を検出し解決するのにどのように役立つかを解説する。

メモリ管理の問題

1. 強参照による循環参照

C# では、ガベージコレクタは到達不可能なオブジェクトのグループを検出して削除することで、循環参照を正しく処理できる。しかし、C++ では、スマートポインタは参照カウントを使用する。もし 2 つのオブジェクトが強参照 (SharedPtr) で相互に参照し合っている場合、プログラムの他の部分からそれらへの外部参照がなくなったとしても、それらの参照カウントは決してゼロにならない。これにより、これらのオブジェクトが占有するリソースが決して解放されず、メモリリークが発生する。

典型的な例を考えてみよう:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this); // Document が Element を参照
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc; // Element が Document を逆参照
    }
}

このコードは以下のように変換される:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    SharedPtr<Document> owner; // 強参照
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

ここで、Document オブジェクトは Element への SharedPtr を含み、Element オブジェクトは Document への SharedPtr を含む。強参照のサイクルが作成される。最初に Document へのポインタを保持していた変数がスコープ外に出ても、相互参照のため、両方のオブジェクトの参照カウントは 1 のままである。オブジェクトは決して削除されない。

これは、サイクルに関与するフィールドの 1 つ、例えば Element.owner フィールドに CppWeakPtr 属性を設定することで解決される。この属性は、トランスレータに対し、このフィールドに弱参照 (WeakPtr) を使用するよう指示する。弱参照は強参照カウントをインクリメントしない。

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
}

class Element {
    [CppWeakPtr] private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

結果として得られる C++ コード:

class Document : public Object {
    SharedPtr<Element> root; // 強参照
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    WeakPtr<Document> owner; // これは弱参照になる
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

これで ElementDocument への弱参照を保持し、サイクルが断ち切られる。最後の外部 SharedPtr<Document> が消滅すると、Document オブジェクトが削除される。これにより root フィールド (SharedPtr<Element>) の削除がトリガーされ、Element の参照カウントがデクリメントされる。他に Element への強参照がなければ、それも削除される。

2. コンストラクション中のオブジェクト削除

この問題は、オブジェクトがコンストラクションの 途中 で、それに対する「永続的な」強参照が確立される前に、SharedPtr を介して別のオブジェクトやメソッドに渡される場合に発生する。この場合、コンストラクタ呼び出し中に作成された一時的な SharedPtr が唯一の参照である可能性がある。呼び出し完了後にそれが破棄されると、参照カウントがゼロになり、まだ完全には構築されていないオブジェクトのデストラクタが即座に呼び出され、削除されてしまう。

例を考えてみよう:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
    public void Prepare(Element elm)
    {
        ...
    }
}

class Element {
    public Element(Document doc)
    {
        doc.Prepare(this);
    }
}

トランスレータは以下を出力する:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // 時期尚早な削除からの保護
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // 時期尚早な削除からの保護
        doc->Prepare(this);
    }
}

Document::Prepare メソッドに入ると、一時的な SharedPtr オブジェクトが作成され、その後、不完全に構築された Element オブジェクトに対する強参照が残っていないため、そのオブジェクトを削除してしまう可能性がある。前の記事で示したように、この問題は Element コンストラクタのコードにローカル変数 ThisProtector guard を追加することで解決される。トランスレータはこれを自動的に行う。guard オブジェクトのコンストラクタは this の強参照カウントを 1 増やし、そのデストラクタはオブジェクトの削除を引き起こすことなく再びそれを減らす。

3. コンストラクタが例外をスローした場合のオブジェクトの二重削除

オブジェクトのコンストラクタが、そのフィールドの一部がすでに作成・初期化された後で例外をスローする状況を考える。これらのフィールドは、構築中のオブジェクトへの強参照を逆方向に含んでいる可能性がある。

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
        throw new Exception("Failed to construct Document object");
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

変換後、以下を得る:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this);
        root = MakeObject<Element>(this);
        throw Exception(u"Failed to construct Document object");
    }
}

class Element {
    SharedPtr<Document> owner;
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this);
        owner = doc;
    }
}

ドキュメントのコンストラクタで例外がスローされ、実行がコンストラクタコードを抜けると、スタックアンワインディングが開始される。これには、不完全に構築された Document オブジェクトのフィールドの削除も含まれる。これにより、削除中のオブジェクトへの強参照を含む Element::owner フィールドが削除される。これは、すでにデストラクション中のオブジェクトの削除を引き起こし、さまざまなランタイムエラーにつながる。

Element.owner フィールドに CppWeakPtr 属性を設定すると、この問題は解決する。しかし、属性が配置されるまで、予測不能な終了のため、このようなアプリケーションのデバッグは困難である。トラブルシューティングを簡素化するために、特別なデバッグビルドモードがある。このモードでは、内部オブジェクト参照カウンタがヒープに移動され、フラグが追加される。このフラグは、オブジェクトが完全に構築された後、つまりコンストラクタを抜けた後の MakeObject 関数のレベルでのみ設定される。フラグが設定される前にポインタが破棄された場合、オブジェクトは削除されない。

4. オブジェクトチェーンの削除

class Node {
    public Node next;
}
class Node : public Object {
public:
    SharedPtr<Node> next;
}

オブジェクトチェーンの削除は再帰的に行われるため、チェーンが長い場合(数千オブジェクト以上)はスタックオーバーフローを引き起こす可能性がある。この問題は、ファイナライザ(デストラクタに変換される)を追加し、反復処理によってチェーンを削除することで解決される。

循環参照の発見

循環参照の問題を修正するのは簡単である – C# コードに属性を追加するだけである。悪い知らせは、C++ 向けの製品リリースを担当する開発者は、通常、どの特定の参照が弱参照であるべきか、あるいはサイクルが存在することさえ知らないということである。

サイクルの探索を容易にするために、我々は同様に動作する一連のツールを開発した。これらは 2 つの内部メカニズムに依存している:グローバルオブジェクトレジストリと、オブジェクトの参照フィールドに関する情報の抽出である。

グローバルレジストリには、現在存在するオブジェクトのリストが含まれる。System::Object クラスのコンストラクタは現在のオブジェクトへの参照をこのレジストリに入れ、デストラクタはそれを削除する。当然ながら、このレジストリは特別なデバッグビルドモードでのみ存在し、リリースモードでの変換済みコードのパフォーマンスに影響を与えないようにしている。

オブジェクトの参照フィールドに関する情報は、System::Object レベルで宣言された仮想関数 GetSharedMembers() を呼び出すことで抽出できる。この関数は、オブジェクトのフィールドに保持されているスマートポインタとそのターゲットオブジェクトの完全なリストを返す。ライブラリコードでは、この関数は手動で記述されるが、生成されたコードではトランスレータによって埋め込まれる。

これらのメカニズムによって提供される情報を処理するには、いくつかの方法がある。それらの切り替えは、適切なトランスレータオプションやプリプロセッサ定数を使用して行われる。

  1. 対応する関数が呼び出されると、現在存在するオブジェクトの完全なグラフ(型、フィールド、関係に関する情報を含む)がファイルに保存される。このグラフは、graphviz ユーティリティを使用して視覚化できる。通常、このファイルは各テストの後に作成され、リークを便利に追跡できるようにする。
  2. 対応する関数が呼び出されると、現在存在するオブジェクトのうち、循環依存関係(関与するすべての参照が強参照である)を持つもののグラフがファイルに保存される。したがって、グラフには関連情報のみが含まれる。すでに分析されたオブジェクトは、後続のこの関数の呼び出しでは分析から除外される。これにより、特定のテストから何がリークしたかを正確に確認するのがはるかに容易になる。
  3. 対応する関数が呼び出されると、現在存在する孤立した島(それらへのすべての参照が同じセット内の他のオブジェクトによって保持されているオブジェクトのセット)に関する情報がコンソールに出力される。静的変数またはローカル変数によって参照されるオブジェクトは、この出力には含まれない。各タイプの孤立した島、すなわち典型的な島を作成するクラスのセットに関する情報は、一度だけ出力される。
  4. SharedPtr クラスのデストラクタは、それが管理するオブジェクトのライフタイムから開始して、オブジェクト間の参照をたどり、検出されたすべてのサイクル(開始オブジェクトに強参照をたどることで再び到達できるすべてのケース)に関する情報を出力する。

もう 1 つの有用なデバッグツールは、MakeObject 関数によってオブジェクトのコンストラクタが呼び出された後、そのオブジェクトの強参照カウントがゼロであることを確認することである。ゼロでない場合、これは潜在的な問題(参照サイクル、例外がスローされた場合の未定義の動作など)を示している。

まとめ

C# と C++ の型システムの根本的な不一致にもかかわらず、我々は変換されたコードが元の動作に近い振る舞いで実行できるようにするスマートポインタシステムを構築することに成功した。同時に、タスクは完全に自動モードで解決されたわけではない。我々は、潜在的な問題の探索を大幅に簡素化するツールを作成した。

関連ニュース

関連動画

関連記事