.NET ガベージ コレクタを C# で書く – パート 6: マーク&スイープ

2026/01/27 21:23

.NET ガベージ コレクタを C# で書く – パート 6: マーク&スイープ

RSS: https://news.ycombinator.com/rss

要約

Japanese Translation:

この投稿では、C# で基本的な .NET ガベージコレクタを構築する方法を説明し、到達可能オブジェクトを特定する マークフェーズ に焦点を当てています。

  1. ルート分類 – 記事ではローカル変数のルート、GC ハンドル、およびファイナライゼーションキューという三つのルートカテゴリが挙げられています。

    GcScanRoots
    がローカル変数のルートだけをスキャンする方法を示し、コールバック (
    ScanRootsCallback
    ) を呼び出して各ルートポインタと
    ScanContext
    を受け取ります。このコンテキスト内で
    _unused1
    フィールドは実際に
    GCHeap
    への参照を保持しており、
    promotion
    フィールドは未使用です。

  2. マークアルゴリズム – 明示的な

    Stack<IntPtr>
    (
    _markStack
    ) を用いて実装された深さ優先探索(DFS)が、オブジェクトをそのメソッドテーブルポインタの最下位ビットを設定することでマークします。
    GCObject
    構造体内のヘルパーメソッド (
    Mark()
    ,
    Unmark()
    ,
    IsMarked()
    ) がこのフラグを操作し、
    MethodTable
    プロパティは安全にアクセスできるようにマスクしています。インテリアポインタ(
    GcCallFlags.GC_CALL_INTERIOR
    )は現在無視されています。

  3. スイープフェーズ – マーク後、

    WalkHeapObjects()
    が全ヒープオブジェクトを反復処理し、各オブジェクトのサイズを
    ComputeSize()
    で計算し、境界を
    Align
    で整列させます。マークされていないオブジェクトは
    Span<byte>.Clear()
    を使用してメモリをクリアし、ヒープが走査可能な状態を保つためにフリーオブジェクトに置き換えられます。マークされたオブジェクトは単にアンマークされるだけです。

  4. コンザーバティブモード – 記事では、コンザーバティブモード (

    DOTNET_gcConservative=1
    ) がまだサポートされていないことが述べられています。これは有効にすると、スタック上の任意の値が GC メモリを指している場合、それをルートとして扱うという意味です。

  5. 今後の作業 – 今後の記事では、GC ハンドル、ファイナライゼーションキュー、およびインテリアポインタへのサポートが追加され、機能的なコレクターを完成させる予定です。

  6. リソース – 完全なソースコードは GitHub に公開されており、読者はより深い洞察のために Pro .NET Memory Management の第2版を参照することが推奨されています。

本文

この記事がお役に立ったら、Pro .NET Memory Managementの第2版もぜひご覧ください。 .NET ガベージコレクタ内部についてさらに深く知ることができます!

長い(むしろ極端に長い)休止期間を経て、C# で .NET ガベージコレクタを構築する旅を再開します。前回のパートでは、単純なアプリケーションが動くように最小限の GC API を実装し、ヒープを走査できるようにオブジェクトをメモリ上に配置する方法を学びました。その後、管理対象オブジェクトの参照を取得する手段も習得しました。復習が必要な場合は、以下の記事へ戻ってください。

  • パート 1 – はじめにとプロジェクト設定
  • パート 2 – 最小 GC の実装
  • パート 3 – DAC を使った管理オブジェクトの検査
  • パート 4 – 管理ヒープの走査
  • パート 5 – GCDesc をデコードしてオブジェクト参照を取得

すべて読む時間がない場合は、ヒープレイアウトを説明したパート 4 に注目することをおすすめします。


マークフェーズ

マークフェーズでは、ユーザーコードから現在到達可能な全オブジェクトを見つけ出し、もう到達不可能になったものが解放できるかどうかを判断します。

ルートの決定

GC はコレクション開始時に「必ず生存」とみなす参照(ルート)から探索を始めます。ルートは次の3種類に分類されます。

  1. ローカル変数とスレッド固有ストレージ
  2. GC ハンドル
  3. ファイナライザキュー

実際には、静的フィールドは GC ハンドルによって生存が保証されています。

GC は後2つのバケットを管理しますが、最初のバケット(ローカル変数など)はランタイム自身が処理します。

IGCToCLR
では
GcScanRoots
メソッドを公開しており、このメソッドはコールバックを受け取り、すべてのローカル変数に対して呼び出されます。さらに、
GcScanRoots
は3つの引数(
condemned
max_gen
(サーバGC の特殊ケースでほぼ無視可)と
ScanContext
)を取ります。

[StructLayout(LayoutKind.Sequential)]
public struct ScanContext
{
    public IntPtr thread_under_crawl;
    public int thread_number;
    public int thread_count;
    public IntPtr stack_limit;          // スキャンロジックが読み取り許可されるスレッドスタックの最下点
    public bool promotion;              // TRUE: Promotion, FALSE: Relocation.
    public bool concurrent;             // TRUE: concurrent scanning
    public IntPtr _unused1;
    public IntPtr pMD;
    public int _unused3;
}

本実装では、

promotion
_unused1
の2つだけを使用します。

  • promotion
    は実行エンジンに「プロモーション(上位世代へ移動)用のマークか、再配置用のマークか」を知らせます。ここでは true を設定しています。
  • _unused1
    は GC が自由に使えるポインタサイズフィールドです。ここには
    GCHeap
    のインスタンスへのポインタを格納します。
    GcScanRoots
    に渡すコールバックは
    [UnmanagedCallersOnly]
    でなければならないため、静的メソッドになります。
private void MarkPhase()
{
    ScanContext scanContext = new();
    scanContext.promotion   = true;
    scanContext._unused1     = GCHandle.ToIntPtr(_handle);

    var scanRootsCallback =
        (delegate* unmanaged<GCObject**, ScanContext*, uint, void>)&ScanRootsCallback;

    _gcToClr.GcScanRoots((IntPtr)scanRootsCallback, 2, 2, &scanContext);
}

[UnmanagedCallersOnly]
private static void ScanRootsCallback(GCObject** obj,
                                      ScanContext* context,
                                      uint flags)
{
    var handle   = GCHandle.FromIntPtr(context->_unused1);
    var gcHeap   = (GCHeap)handle.Target!;
    gcHeap.ScanRoots(*obj, context, (GcCallFlags)flags);
}

private void ScanRoots(GCObject* obj,
                       ScanContext* context,
                       GcCallFlags flags)
{
    // TODO: 実際のコールバック実装
}

コンザーバティブモード

「コンザーバティブモード」という用語を使う前に、まずは説明しておきます。

DOTNET_gcConservative=1
を設定すると、実行エンジンは 正確 ルート追跡(どこがルートか明確に分かる)から 保守的 ルート追跡へ切り替わります。保守的モードでは、スタック全体をスキャンし、GC が管理するメモリ領域にポインタを指す値をすべて報告します。その結果、誤検出(false positive)が増え、GC の負荷が大きくなります。主に CLR が完全に実装されていない環境や、新機能のテスト時に使われます。現在のカスタム GC ではコンザーバティブモードへの対応は予定していません。


ScanRoots コールバック

ScanRoots
のコールバック内で、与えられたオブジェクトから出てくるすべての参照を検出し、その参照ツリーを走査して見つかったオブジェクトをマークします。パート 5 で「あるオブジェクトの参照を取得する」方法を学び、
EnumerateObjectReferences
メソッドに実装しました。ここでは、オブジェクトがマーク済みかどうかを記録する必要があります。

いくつかの手段があります。x64 ならオブジェクトヘッダーにパディングがあり、その領域を再利用できるかもしれません。しかし、後で他の最適化に使う予定なので、今回は実際の .NET GC が行っている方法を採用します。

オブジェクトレイアウト

メモリ上のオブジェクトは次のようになっています。

                      Object
                +-----------------+
                | Object header   |
                +-----------------+
Object ref ---> | MethodTable*    |
                +-----------------+
                |  Field1         |
                +-----------------+
                |  Field2         |
                +-----------------+
                |  Field3         |
                +-----------------|
                |  Field4         |
                +-----------------+

ポイントは、メソッドテーブルポインタがポインタ境界に揃えてあるため、32ビット環境では最下位2ビット(64ビットなら3ビット)が常に0であることです。GC はオブジェクトをマークする際に、その最低位ビットを 1 に設定します。コレクション終了時には元のポインタに戻す必要があります。

GCObject
構造体に
Mark
,
Unmark
,
IsMarked
、そして
MethodTable
プロパティ(マーク状態を無視してメソッドテーブルにアクセス)を追加します。

[StructLayout(LayoutKind.Sequential)]
public unsafe struct GCObject
{
    public MethodTable* RawMethodTable;
    public uint Length;

    public readonly MethodTable* MethodTable =>
        (MethodTable*)((nint)RawMethodTable & ~1);

    public bool IsMarked() => ((nint)RawMethodTable & 1) != 0;

    public void Mark()   => RawMethodTable = (MethodTable*)((nint)MethodTable | 1);
    public void Unmark() => RawMethodTable = (MethodTable*)((nint)MethodTable & ~1);

    // …
}

DFS による走査

実際の参照ツリーを探索するには、再帰を使わずにスタックで深さ優先探索(DFS)を行います。

GC_CALL_INTERIOR
フラグが付いたルートは現時点では無視します。

private Stack<IntPtr> _markStack = new();

private void ScanRoots(GCObject* obj,
                       ScanContext* context,
                       GcCallFlags flags)
{
    if ((IntPtr)obj == 0) return;

    if (flags.HasFlag(GcCallFlags.GC_CALL_INTERIOR))
    {
        // TODO: interior pointer を扱う
        return;
    }

    _markStack.Push((IntPtr)obj);

    while (_markStack.Count > 0)
    {
        var ptr = _markStack.Pop();
        var o   = (GCObject*)ptr;

        if (o->IsMarked()) continue;

        o->EnumerateObjectReferences(_markStack.Push);
        o->Mark();
    }
}

interior pointer の問題

例えば次のようなコードを考えてみてください。

private static ref int GetInteriorPointer()
{
    var array = new int[10];
    return ref array[5];
}

GetInteriorPointer
は配列内の要素への参照(interior pointer)を返します。GC がその配列を回収してしまうと、残っている
ref int
から不正アクセスが起きます。このような interior pointer を正しく扱うには、ポインタからオブジェクト開始位置を特定しマークする必要があります。現時点では、この問題は無視しています。


スイーピング

ルートにより到達可能なオブジェクトをすべてマークした後、ヒープ全体を走査して未マークのオブジェクトを解放します。現在の実装ではメモリ再利用は行わず、単純に領域をクリアしています。ただし、ヒープが「歩きやすい」状態を保つために、古いオブジェクトの代わりにフリーオブジェクト(パート 4 で説明)を配置します。マーク済みだった場合は必ず

Unmark
を呼び出して元のメソッドテーブルポインタに戻します。

private void Sweep()
{
    foreach (IntPtr ptr in WalkHeapObjects())
    {
        var obj   = (GCObject*)ptr;
        bool marked = obj->IsMarked();
        obj->Unmark();

        bool isFreeObject = obj->MethodTable == _freeObjectMethodTable;

        if (!marked && !isFreeObject)
        {
            var startPtr = ptr - IntPtr.Size; // ヘッダーを含む
            var endPtr   = Align(startPtr + (nint)obj->ComputeSize());

            // メモリをクリア
            new Span<byte>((void*)startPtr, (int)(endPtr - startPtr)).Clear();

            // フリーオブジェクトで置き換えてヒープを走査可能に保つ
            AllocateFreeObject(ptr,
                               (uint)(endPtr - startPtr - SizeOfObject));
        }
    }
}

private IEnumerable<IntPtr> WalkHeapObjects()
{
    foreach (var segment in _segments)
    {
        foreach (var obj in WalkHeapObjects(segment.Start, segment.Current))
        {
            yield return obj;
        }
    }
}

private IEnumerable<IntPtr> WalkHeapObjects(nint start, nint end)
{
    var ptr = start + IntPtr.Size;

    while (ptr < end)
    {
        yield return ptr;
        ptr = FindNextObject(ptr);
    }

    static unsafe nint FindNextObject(nint current)
    {
        var obj = (GCObject*)current;
        return Align(current + (nint)obj->ComputeSize());
    }
}

まとめ

これで基本的なマーク&スイープサイクルは実装できました。実際にアプリケーションを走らせると、まだ interior pointer の処理や GC ハンドル・ファイナライザキューの対応が欠けているためクラッシュします。次回の記事ではそれらを追加し、完全なガベージコレクタへと仕上げていきます。

詳細コードは GitHub で公開していますので、ご興味のある方はぜひご確認ください。

同じ日のほかのニュース

一覧に戻る →

2026/02/01 7:05

SwiftはRustよりも便利なプログラミング言語です。

## Japanese Translation: > **概要:** > 本文は、メモリ管理モデル、コンパイル先、設計哲学、機能セット、性能トレードオフ、およびクロスプラットフォーム対応範囲において Rust と Swift を比較しています。 > • **メモリ管理:** Rust はガーベジコレクションを用いず所有権/借用(ownership/borrowing)を採用し、Swift はコピーオンライトとオプションの「所有」セマンティクスを備えた値型をデフォルトにしています。両方とも unsafe な生ポインタをサポートします。 > • **コンパイル:** 両言語は LLVM を介してネイティブコードへコンパイルし、WebAssembly(WASM)もサポートします。 > • **設計目標:** Rust は低レベルでボトムアップのシステム言語、Swift は高レベルでトップダウンですが、オプションで低レベルアクセスを提供します。 > • **コピーオンライトと再帰:** Rust では明示的に `Cow<>` と `.as_mutable()` を使用してコピーオンライトを行い、再帰型循環を解消するには `Box<>`(または `Rc/Arc`)が必要です。Swift はコピーオンライトを自動化し、再帰を扱うために `indirect` キーワードを利用します。 > • **エラーハンドリング:** Rust の `Result<T,E>` と `?` 演算子;Swift の `do‑catch` と `try`。 > • **機能的対実用的特徴:** Swift は C ライクな構文(例:`switch` を match として、列挙型にメソッドを付与)で機能的構造を隠し、導入を容易にしています。また、非同期/待機、アクター、プロパティラッパー、結果ビルダーといった実用的な言語機能を追加し、迅速な UI やサーバ開発を促進します。Rust はよりミニマリスティックですが、細かい制御が可能です。 > • **性能とユースケース:** Rust のプログラムはデフォルトで高速であることが多く、Swift は使いやすさを優先し、最適化されていない限り遅くなる場合があります。そのため、Rust は低レベルシステム作業に好まれ、Swift は迅速な UI やサーバ開発を求める開発者に適しています。 > • **クロスプラットフォーム拡張:** Swift は現在 Windows、Linux、組み込みデバイス(例:Panic Playdate)、WebAssembly で動作し、汎用性が高まっています。ただし、コンパイル時間の長さ、機能セットの大きさ、Rust に比べて成熟度の低いパッケージエコシステムといった課題も残ります。

2026/02/01 2:21

モバイルキャリアは、あなたのGPS位置情報を取得できることがあります。

## Japanese Translation: Appleの次期iOS 26.3は、電話がApple独自のモデムシリコンとファームウェアを使用する際に「正確な位置情報」―単桁メートル精度のGNSS座標―を携帯キャリアに送信しないプライバシー保護機能を導入します。これは2025年に発売されるデバイスで利用可能です。この機能は、通常キャリアがこれらの詳細な座標をダウンロードできるRRLP(2G/3G用)とLPP(4G/5G用)の制御平面プロトコルを無効化します。Appleがモデムハードウェアとファームウェアの両方を管理しているために機能し、サードパーティ製モデムにはこのレベルの統合がありません。 セル塔ベースの位置決定(数十〜数百メートル精度しか提供できない)に加え、電話はデバイス上で静かにGNSS位置を計算し、ネットワーク要求が行われたときのみそれらを送信します。そうでなければ携帯端末からは何もデータが離れません。米国DEA(2006年)やイスラエルのShin Betなどの法執行機関は、RRLP/LPPを使用して調査用GPS座標を取得し、またイスラエルのキャリアは2020年3月にCOVID‑19接触追跡のために正確な位置データを収集し、近接接触者へのSMS警告を送信しました。 Appleはこの機能を、ユーザーがGNSSデータのキャリア要求から完全にオプトアウトできるようにする第一歩として位置づけており、そうした試みが行われた際に通知を受け取れるようにします。Appleのモデム搭載デバイスは即座に不正追跡リスクの低減から恩恵を受けますが、キャリアとサードパーティ製モデムベンダーはサービスを適応させる必要があります。本機能の展開はまだApple以外のモデム搭載デバイスには適用されていません。 *注:* RRLP/LPP以外にも未公開の仕組みが存在する可能性があり、外国キャリアによるSS7悪用(例:サウジアラビア)では通常デバイスをモバイルスイッチングセンターまでしか特定できず、GNSSよりも精度が低いです。

2026/02/01 6:14

**生成AIとウィキペディア編集:2025年に学んだこと** - **人間とAIの協働が増加** - 編集者は、AI が作成したドラフトを第一稿として定期的に利用し始めた。 - 人間のレビュアーが引用を追加し、事実確認・トーン調整を行った。 - **品質保証の強化** - 新しいAI駆動型ファクトチェックツールで、公開前に矛盾点を検出した。 - 自動スタイルチェックにより、ウィキペディアのマニュアル・オブ・スタイルへの準拠が確保された。 - **コミュニティの受容とガバナンス** - ウィキメディア財団は、許容されるAI貢献を明記したガイドラインを導入。 - AI関与の透明なログ作成がすべての編集に対して必須となった。 - **偏見緩和への取り組み** - バイアス検出アルゴリズムが特定トピックでの過剰表現を指摘。 - 編集監視チームは偏向した視点を修正し、多様な観点を追加した。 - **パフォーマンス指標** - 平均編集完了時間が2024年比で約30 %短縮された。 - AI支援による記事更新数は12 %から28 %へと増加した。 - **今後の方向性** - AI生成引用文献の継続的改善。 - 英語以外のウィキペディア版への多言語サポート拡充。 **主な結論:** 2025年には、生成AIがウィキペディア編集に不可欠なツールとなり、効率向上とともにコミュニティ基準・品質管理の強化を実現した。

## Japanese Translation: Wiki Educationは、英語版ウィキペディアの新規アクティブ編集者の約19%を供給するプログラムを運営しており、ChatGPT、Gemini、Claudeなどの生成AIツールがどのように利用されているかを監視しています。 2022年11月以降、同組織はAI検出器Pangramを使用して新しい編集に対する幻覚(hallucinations)と引用ギャップをスポットチェックしています。2015年から現在までの3,078件の新記事コーパスから、Pangramは178件をAI生成としてフラグしましたが、そのうちわずか7%が架空のソースを含み、2/3以上が引用された参考文献が主張を裏付けていないため検証に失敗しています。 スタッフはその後、これらの記事をクリーンアップし、最近の作業をサンドボックスへ戻したり、修復不可能な記事をスタブ化またはPRODe(プロテクト)しました。また、2025年にPangramをダッシュボードプラットフォームに統合し、ほぼリアルタイムで検出できるようにしています。2025年秋だけでも1,406件のAIアラートが記録され、そのうち314件(22%)がライブページに影響しました。さらに、217名の参加者(新規編集者6,357人中3%)が複数回アラートを受けました。この介入により、本空間でのAIコンテンツの予測比率は約25%から約5%へと削減されました。 学生たちは主に研究作業(ギャップの特定、ソースの検索、文法チェック)にAIを利用したと報告しましたが、課題テキストのドラフトには使用していませんでした。 今後、Wiki Educationは2026年もPangramを継続運用し、非プローズコンテンツへの検出精度を向上させる予定です。また、オプションのLLMリテラシーモジュールを提供しつつ、メールと動画による自動化トレーニングも継続します。