用 C# 写一个 .NET 垃圾回收器(二)

用 C# 写一个 .NET 垃圾回收器(二)

在第一部分中,我们准备了项目,并修复了由 NativeAOT 工具链引起的初始化问题。在本部分,我们将开始实现自己的 GC(垃圾回收器)。目前的目标是构建一个尽可能简单的 GC,使其能够运行基本的 .NET 应用程序。这个 GC 只会进行内存分配,而不会回收内存,类似于 Konrad Kokosa 提出的 bump-pointer GC(碰撞指针 GC)。

第一步是编写 GC 需要的本地接口。目前有以下四个接口:

  • IGCToCLR:提供可供 GC 调用的执行引擎 API(例如挂起线程)。
  • IGCHeap:主要的 GC API。
  • IGCHandleManager:提供创建或销毁句柄的 API。
  • IGCHandleStore:句柄管理器使用的底层存储。

其中,IGCToCLR 由运行时提供,而其他三个接口需要由 GC 自行实现。

为了处理与本地代码的互操作,我们将使用与托管分析器(managed profiler)相同的 NativeObjects 库。我们需要做的就是在 C# 中定义接口,并使用 [NativeObject] 特性进行标注:

c# 复制代码
[NativeObject]
public unsafe interface IGCHeap
{
    /// <summary>
    /// Returns whether or not the given size is a valid segment size.
    /// </summary>
    bool IsValidSegmentSize(nint size);

    /// <summary>
    /// Returns whether or not the given size is a valid gen 0 max size.
    /// </summary>
    bool IsValidGen0MaxSize(nint size);

    /// <summary>
    /// Gets a valid segment size.
    /// </summary>
    nint GetValidSegmentSize(bool large_seg = false);

    [...]
}

接下来,我们可以通过两种方式获取接口的实现:

  1. 获取托管实现的本地指针(native pointer)。

    c# 复制代码
    var gcHeap = new GCHeap();
    IntPtr gcHeapPtr = NativeObjects.IGCHeap.Wrap(gcHeap);
    // Give the pointer to native code
    // ...
  2. 获取本地实现的托管指针(managed pointer)。

    c# 复制代码
    // Receive the pointer from native code
    IntPtr ptr = ...
    var gcHeap = NativeObjects.IGCHeap.Wrap(ptr);
    // Use gcHeap like a normal managed object

这些托管接口本质上是 .NET 源代码中 C++ 接口的 C# 转换版本,因此这里不再详细介绍。

句柄存储(Handle Store)

GC 句柄(GC Handles)在 .NET 运行时中是一个非常基础的概念,即便是我们这个简单的 GC 也需要提供一定程度的支持。幸运的是,由于我们的 GC 不会释放或移动内存,我们目前可以采用相对简单的实现方式。

一个 GC 句柄包含以下三部分信息:

  1. 句柄类型(弱引用、强引用、固定引用(pinned)等)。
  2. 对象地址(句柄所指向的对象的内存地址)。
  3. 一个指针大小的额外信息字段(用于存储额外的元数据)。

因此,我们可以用一个结构体来表示 GC 句柄:

c# 复制代码
[StructLayout(LayoutKind.Sequential)]
public struct ObjectHandle
{
    public nint Object;
    public nint ExtraInfo;
    public HandleType Type;

    public override string ToString() => $"{Type} - {Object:x2} - {ExtraInfo:x2}";
}

public enum HandleType
{
    HNDTYPE_WEAK_SHORT = 0,
    HNDTYPE_WEAK_LONG = 1,
    HNDTYPE_WEAK_DEFAULT = 1,
    HNDTYPE_STRONG = 2,
    HNDTYPE_DEFAULT = 2,
    HNDTYPE_PINNED = 3,
    HNDTYPE_VARIABLE = 4,
    HNDTYPE_REFCOUNTED = 5,
    HNDTYPE_DEPENDENT = 6,
    HNDTYPE_ASYNCPINNED = 7,
    HNDTYPE_SIZEDREF = 8,
    HNDTYPE_WEAK_NATIVE_COM = 9
}

目前,并非所有句柄类型都需要额外信息,因此理论上,我们可以为不同的句柄类型设计专门的结构体来节省空间。但对于我们这个简单的 GC 来说,这并不重要,因此不会做这种优化。

目前,我们可以使用一个固定大小的数组 来存储句柄。为了简化实现,我们将句柄的最大数量硬编码为 10,000 ,这个数量足以支持测试应用程序的运行。然而,由于我们的 GC 不会回收内存,这意味着对于运行时间较长的应用程序,句柄总会被耗尽。因此,未来我们可能需要重新设计这部分逻辑。

c# 复制代码
public unsafe class GCHandleStore : IGCHandleStore
{
    private const int MaxHandles = 10_000;

    private readonly NativeObjects.IGCHandleStore _nativeObject;
    private readonly ObjectHandle* _store;
    private int _handleCount;

    public GCHandleStore()
    {
        _nativeObject = NativeObjects.IGCHandleStore.Wrap(this);
        _store = (ObjectHandle*)NativeMemory.AllocZeroed(MaxHandles, (nuint)sizeof(ObjectHandle));
    }

    public IntPtr IGCHandleStoreObject => _nativeObject;

    // TODO: Implement IGCHandleStore methods
}

IGCHandleStoreObject 属性用于暴露本地指针(native pointer),以便将其传递给 .NET 运行时。

关于句柄存储的设计选择

在实现过程中,我曾考虑过使用固定的 ObjectHandle[] 数组 (并通过 pinned 关键字固定其地址)来替代直接使用本地内存。然而,最终我认为这种做法有些"投机取巧"(cheating),因此决定仅依赖 NativeAOT GC 来管理元数据结构,而所有暴露给 .NET 运行时的内容都应当由我们手动管理并存储在本地内存中。

现在我们可以实现句柄生成。IGCHandleStore 接口的多个方法都运行相同的逻辑:

c# 复制代码
public unsafe ref ObjectHandle CreateHandleOfType(GCObject* obj, HandleType type)
{
	return ref CreateHandleWithExtraInfo(obj, type, null);
}

public unsafe ref ObjectHandle CreateHandleOfType2(GCObject* obj, HandleType type, int heapToAffinitizeTo)
{
	return ref CreateHandleWithExtraInfo(obj, type, null);
}

public unsafe ref ObjectHandle CreateDependentHandle(GCObject* primary, GCObject* secondary)
{
	return ref CreateHandleWithExtraInfo(primary, HandleType.HNDTYPE_DEPENDENT, secondary);
}

public unsafe ref ObjectHandle CreateHandleWithExtraInfo(GCObject* obj, HandleType type, void* pExtraInfo)
{
    var index = Interlocked.Increment(ref _handleCount) - 1;

    if (index >= MaxHandles)
    {
    Environment.FailFast("Too many handles");
    }

    ref var handle = ref _store[index];

    handle.Object = (nint)obj;
    handle.Type = type;
    handle.ExtraInfo = (nint)pExtraInfo;

    return ref handle;
}

GCObject* 代表一个指向托管对象 (managed object)的指针。虽然目前我们不会对其进行解引用(dereference),但 GCObject 结构的布局模仿了 .NET 运行时中的托管对象格式。

c# 复制代码
[StructLayout(LayoutKind.Sequential)]
public readonly struct GCObject
{
    public readonly IntPtr MethodTable;
    public readonly int Length;
}

IGCHandleStore 接口 还暴露了一个 ContainsHandle 方法,不过在 .NET 运行时中似乎没有地方实际使用它。不过,由于实现起来相对简单,我们还是会提供该方法。此外,我们还添加了一个 DumpHandles 方法,以便在调试 GC 时查看当前句柄的状态。

c# 复制代码
public void DumpHandles()
{
    Write("GCHandleStore DumpHandles");

    for (int i = 0; i < _handleCount; i++)
    {
        Write($"Handle {i} - {_store[i]}");
    }
}

public bool ContainsHandle(ref ObjectHandle handle)
{
    var ptr = Unsafe.AsPointer(ref handle);
    return ptr >= _store && ptr < _store + _handleCount;
}

目前来说,这些实现已经足够满足我们的需求。不过,将来我们可能会引入更复杂的句柄存储机制,比如:

  • 基于段(segment-based)的存储结构,可以根据需要动态扩展。
  • 空闲列表(free-list),用于重用已释放的句柄。

句柄管理器(Handle Manager)

在 .NET 运行时中,句柄的管理涉及两个接口:IGCHandleManager 和 IGCHandleStore。但为什么需要这两个不同的接口?老实说,我也不太确定。理论上,这两个接口完全可以合并。

从历史角度来看,这可能源自 .NET 早期的设计,当时运行时可能存在多个句柄存储(handle store),比如与 AppDomains 相关的机制。不过,现在这种情况已经不复存在了。

IGCHandleManager 主要提供以下功能:

  • 访问底层的 IGCHandleStore。
  • 读取或修改句柄的元信息。

这样,执行引擎(execution engine) 无需关心 GC 在内存中如何存储和管理句柄。

c# 复制代码
internal unsafe class GCHandleManager : IGCHandleManager
{
    private readonly NativeObjects.IGCHandleManager _nativeObject;
    private readonly GCHandleStore _gcHandleStore;

    public GCHandleManager()
    {
        _gcHandleStore = new GCHandleStore();
        _nativeObject = NativeObjects.IGCHandleManager.Wrap(this);
    }

    public IntPtr IGCHandleManagerObject => _nativeObject;

    public GCHandleStore Store => _gcHandleStore;

    public bool Initialize()
    {
        return true;
    }

    public IntPtr GetGlobalHandleStore()
    {
        return _gcHandleStore.IGCHandleStoreObject;
    }

    public unsafe ref ObjectHandle CreateGlobalHandleOfType(GCObject* obj, HandleType type)
    {
        return ref _gcHandleStore.CreateHandleOfType(obj, type);
    }

    public ref ObjectHandle CreateDuplicateHandle(ref ObjectHandle handle)
    {
        ref var newHandle = ref _gcHandleStore.CreateHandleOfType((GCObject*)handle.Object, handle.Type);
        newHandle.ExtraInfo = handle.ExtraInfo;
        return ref newHandle;
    }

    public unsafe void SetExtraInfoForHandle(ref ObjectHandle handle, HandleType type, nint extraInfo)
    {
        handle.ExtraInfo = extraInfo;
    }

    public unsafe nint GetExtraInfoFromHandle(ref ObjectHandle handle)
    {
        return handle.ExtraInfo;
    }

    public unsafe void StoreObjectInHandle(ref ObjectHandle handle, GCObject* obj)
    {
        handle.Object = (nint)obj;
    }

    public unsafe bool StoreObjectInHandleIfNull(ref ObjectHandle handle, GCObject* obj)
    {
        var result = InterlockedCompareExchangeObjectInHandle(ref handle, obj, null);        
        return result == null;
    }

    public unsafe void SetDependentHandleSecondary(ref ObjectHandle handle, GCObject* obj)
    {
        handle.ExtraInfo = (nint)obj;
    }

    public unsafe GCObject* GetDependentHandleSecondary(ref ObjectHandle handle)
    {
        return (GCObject*)handle.ExtraInfo;
    }

    public unsafe GCObject* InterlockedCompareExchangeObjectInHandle(ref ObjectHandle handle, GCObject* obj, GCObject* comparandObject)
    {
        return (GCObject*)Interlocked.CompareExchange(ref handle.Object, (nint)obj, (nint)comparandObject);
    }

    public HandleType HandleFetchType(ref ObjectHandle handle)
    {
        return handle.Type;
    }
}

注意 :这里仅展示了我们目前实现的部分方法。其他与句柄释放相关的方法暂时不需要,因此未作实现。

IGCHeap

IGCHeap 是 GC 的核心接口,通常提到 GC API 时,我们想到的就是它。这个接口非常庞大,包含多达 88 个方法 ,但对于我们的简化版 GC,我们只需要实现其中的一部分关键方法。

GCHeap 类的构造函数与 IGCHandleStore 和 IGCHandleManager 的实现类似,主要用于初始化本地互操作的封装(native interop wrappers)。

c# 复制代码
internal unsafe class GCHeap : Interfaces.IGCHeap
{
    private readonly IGCToCLRInvoker _gcToClr;
    private readonly GCHandleManager _gcHandleManager;
    private readonly IGCHeap _nativeObject;

    public GCHeap(IGCToCLRInvoker gcToClr)
    {
        _gcToClr = gcToClr;
        _gcHandleManager = new GCHandleManager();

        _nativeObject = IGCHeap.Wrap(this);
    }

    public IntPtr IGCHeapObject => _nativeObject;
    public IntPtr IGCHandleManagerObject => _gcHandleManager.IGCHandleManagerObject;
}

Initialize 方法是 GC 需要实现的第一个关键方法。这个方法会在运行时初始化的早期 被调用,让 GC 预先准备好托管代码运行所需的一切。在 .NET 的正式 GC 中,Initialize 主要用于:

  • 计算内存段(segment)或区域(region)的大小。
  • 预分配堆(heap)。
  • 进行必要的内存管理设置。

不过,由于我们这个 GC 仅仅是最基础的实现 ,所以不需要做这些复杂的初始化。但是,我们仍然需要设置写屏障(write barrier)。

这个例子很好地展示了独立 GC API (Standalone GC API)实际上只是围绕标准 .NET GC 的一层封装 ,而不是一个真正面向自定义 GC 设计的合理 API。即便我们不打算使用卡表(card table) ,API 依然没有提供关闭或修改**写屏障(write barrier)**的方式,因此我们仍然需要正确地进行初始化。

幸运的是,Konrad Kokosa 在他的 GC 实现中使用了一个技巧 。在 Workstation GC 模式下,写屏障在写入卡表之前会检查目标地址是否位于 GC 管理的范围内 。我们可以利用这一点,将 GC 的范围 设置为一个特殊值,使得所有地址都超出这个范围 ,从而间接禁用写屏障。

c# 复制代码
public HResult Initialize()
{
    Write("Initialize GCHeap");

    var parameters = new WriteBarrierParameters
    {
        operation = WriteBarrierOp.Initialize,
        is_runtime_suspended = true,
        ephemeral_low = -1 // nuint.MaxValue
    };

    _gcToClr.StompWriteBarrier(&parameters);

    return HResult.S_OK;
}

我们可以设置 GC 地址范围的最低值(ephemeral_low)为最大可能的地址 ,这样所有对象的地址都会超出 GC 监控的范围。这使得写屏障不会执行卡表更新,从而达到**"屏蔽"写屏障的效果**。

如果不使用这个技巧,我们就需要:

  • 为卡表分配内存 ,并将其赋值给 WriteBarrierParameters 结构体的 card_table 字段。
  • 确保卡表足够大,可以覆盖整个堆(card table 的每个字节映射 2KB 内存)。

然而,这会带来两个问题:

  1. 如何管理堆的边界(bookkeeping)?
    我们使用 NativeMemory.Alloc 进行内存分配,而它返回的地址范围是不可预测的,因此我们很难计算堆的精确范围。
  2. 如何预分配足够大的卡表?
    如果无法动态调整大小,我们可能需要分配一个超大的卡表,以适应可能的堆大小,这会造成内存浪费。

遗憾的是,这个技巧仅适用于 Workstation GC 。在 Server GC 模式下,写屏障不会检查目标地址是否在 GC 管理的范围内,因此必须提供一个有效的卡表 。目前,我们的方案不会支持 Server GC (毕竟对于我们的 GC 来说,Server GC 这个概念本身就没有意义,这也是 .NET GC 设计细节"泄漏"到独立 GC API 的又一例证)。

Alloc 是 GC 最核心的方法 ,每当线程需要内存来分配新对象时,都会调用它。因此,我们必须引入**"分配上下文"(Allocation Context)**的概念。

如果每次分配对象时,线程都必须向 GC 申请内存,那会导致严重的性能问题 。为了解决这个问题,GC 采用了分配上下文(allocation context):

  • GC 会给每个线程分配一块内存,称为 "分配上下文"。
  • 线程可以在这个分配上下文中自行分配对象,无需每次都请求 GC。
  • 只有当分配上下文耗尽 或在特殊情况下(如分配带有终结器的对象)时,线程才会请求 GC 进行新的内存分配。

下面是分配上下文的基本结构(即 GC 分配给每个线程的内存块):

c# 复制代码
[StructLayout(LayoutKind.Sequential)]
public unsafe struct gc_alloc_context
{
    public nint alloc_ptr;
    public nint alloc_limit;
    public long alloc_bytes;
    public long alloc_bytes_uoh;

    public void* gc_reserved_1;
    public void* gc_reserved_2;
    public int alloc_count;
}

alloc_ptr 指向当前分配上下文(allocation context)中的可用位置,而 alloc_limit 则标记了分配上下文的结束位置。此外,分配上下文中还包含多个字段,用于跟踪该线程的分配统计信息,以及两个指针大小的字段,这些字段由 GC 自行决定如何使用。在标准 GC 中,这些字段用于标记线程关联的堆(heap affinity),我在之前的文章中也曾利用过这一点。

现在来说,我们的分配策略保持简单:

  1. 当调用 Alloc 时,首先检查当前分配上下文是否还有足够的空间:
    • 如果有空间,我们只需递增 alloc_ptr,即可完成对象分配。
    • 这种方式避免了频繁向 GC 申请内存,提高了分配效率。
  2. 如果分配上下文不够大:
    • 我们分配32KB 的新内存块,作为新的分配上下文。
    • 特殊情况:如果要分配的对象大于 32KB ,则直接分配精确大小的内存块,而不是使用标准的 32KB 块。
c# 复制代码
public GCObject* Alloc(ref gc_alloc_context acontext, nint size, GC_ALLOC_FLAGS flags)
{
    var result = acontext.alloc_ptr;
    var advance = result + size;

    if (advance <= acontext.alloc_limit)
    {
    // The allocation context is big enough for this allocation
    acontext.alloc_ptr = advance;
    return (GCObject*)result;
    }

    // The allocation context is too small, we need to allocate a new one
    var growthSize = Math.Max(size, 32 * 1024) + IntPtr.Size;
    var newPages = (IntPtr)NativeMemory.AllocZeroed((nuint)growthSize);

    var allocationStart = newPages + IntPtr.Size;
    acontext.alloc_ptr = allocationStart + size;
    acontext.alloc_limit = newPages + growthSize;

    return (GCObject*)allocationStart;
}

你可能会注意到,我们对分配上下文的起始地址进行了偏移 (即 alloc_ptr 向后移动 IntPtr.Size 字节)。其原因如下:

  • Alloc 需要返回一个 GCObject*,即指向托管对象的指针。

  • 在 .NET 中,托管对象的引用并不会直接指向对象的起始位置 ,而是指向对象的 Method Table 指针。

  • 对象的起始位置前面有一个指针大小的字段,用于存储对象头(Object Header)。

  • 因此,我们在分配对象时,需要偏移 IntPtr.Size,确保返回的指针正确指向 Method Table。

    +-----------------+
    | Object header |
    +-----------------+
    | MethodTable* | <----- GCObject*
    +-----------------+
    | |
    | Data |
    | |
    +-----------------+

这个设计确保了 .NET 运行时在访问对象时,能够正确地找到该对象的类型信息和元数据。

IGCHeap 接口中,我们还需要实现最后一个方法:GarbageCollect 。

目前,我们不会真的执行垃圾回收(GC),但可以用它来输出调试信息,例如:

  • 转储(dump)当前句柄存储的信息。
  • 打印内存分配情况,方便后续优化 GC 实现。
c# 复制代码
public HResult GarbageCollect(int generation, bool low_memory_p, int mode)
{
    Write("GarbageCollect");
    _gcHandleManager.Store.DumpHandles();
    return HResult.S_OK;
}

基本功能已完成

我们已经实现了所有必要的 GC 接口,现在可以在 GC_Initialize 方法中将它们连接起来。

c# 复制代码
[UnmanagedCallersOnly(EntryPoint = "_GC_Initialize")]
    public static unsafe HResult GC_Initialize(IntPtr clrToGC, IntPtr* gcHeap, IntPtr* gcHandleManager, GcDacVars* gcDacVars)
    {
    Write("GC_Initialize");

var clrToGc = NativeObjects.IGCToCLR.Wrap(clrToGC);
var gc = new GCHeap(clrToGc);

*gcHeap = gc.IGCHeapObject;
*gcHandleManager = gc.IGCHandleManagerObject;

return HResult.S_OK;
}

由于我们的 GC 不支持 Server GC 模式,我们需要额外的代码来:

  1. 通过 IClrToGC 接口获取运行时的 GC 配置。
  2. 如果 Server GC 处于启用状态,则直接终止初始化。

在本地代码 IGCToCLR.GetBooleanConfigValue 方法中,参数必须是单字节编码的字符串 ,而 .NET 的 string 采用 UTF-16 (两个字节存储一个字符)。为了避免字符串转换,我们使用 u8 后缀来直接获取 UTF-8 字符串。

c# 复制代码
[UnmanagedCallersOnly(EntryPoint = "_GC_Initialize")]
public static unsafe HResult GC_Initialize(IntPtr clrToGC, IntPtr* gcHeap, IntPtr* gcHandleManager, GcDacVars* gcDacVars)
{
    Write("GC_Initialize");

    var clrToGc = NativeObjects.IGCToCLR.Wrap(clrToGC);

    fixed (byte* privateKey = "gcServer"u8, publicKey = "System.GC.Server"u8)
    {
        clrToGc.GetBooleanConfigValue(privateKey, publicKey, out var gcServerEnabled);

        if (gcServerEnabled)
        {
            Write("This GC isn't compatible with server GC. Set DOTNET_gcServer=0 to disable it.");
            return HResult.E_FAIL;
        }
    }

    var gc = new GCHeap(clrToGc);

    *gcHeap = gc.IGCHeapObject;
    *gcHandleManager = gc.IGCHandleManagerObject;

    return HResult.S_OK;
}

为了测试这个 GC,我们编写了一个简单的控制台应用,它会:

  1. 分配几个对象(包括一个大对象)。
  2. 操作一个 DependentHandle(用于验证 IHandleStore 代码)。
  3. 调用 GC.Collect(),触发 DumpHandles 代码,输出当前句柄信息。

虽然测试用例并不复杂,但它可以完整运行且不会崩溃! 在控制台中,大部分日志信息是运行时调用的 GC 方法,而这些方法我们尚未完全实现。

为了更严谨地验证 GC,我们尝试运行了 OrchardCore.Samples 仪表盘应用(在升级到 .NET 9 并禁用 Server GC 之后),未发现明显的错误。

当然,目前的 GC 仍然存在以下问题:

  • 所有已分配的内存都无法回收 ,导致严重的内存泄漏。
  • 句柄的最大数量是固定的,达到上限后,应用程序一定会崩溃。

要让这个 GC 真正运行在生产环境中,仍有大量工作需要完成。

下一步,我们将增加诊断代码,用于显示托管对象的详细信息,以便后续调试。这可能听起来很简单,但由于 GC 运行在自己的运行时中,它无法使用反射(Reflection)或 .NET 标准 API 来检查托管对象,这给调试带来了一定挑战。

本文示例代码已上传到 GitHub,感兴趣的读者可以自行下载研究。

原文链接

https://minidump.net/writing-a-net-gc-in-c-part-2/