Austin Wise
Go to home page
2024-03-31

Lesser known CLR GC Handles

The .NET garbage collector exposes a powerful mechanism for manipulating the lifetime of objects: the GC handle. While .NET developers may be familiar with the five types of GC handles exposed by the .NET runtime through public interfaces like WeakReference, GCHandle, and DependentHandle, there are a total ten types of handles internally implemented by the .NET CoreCLR garbage collector. This post explores the different types of GCHandles and how they are used internally by the .NET runtime.

Garbage collection background

The garbage collector is one of the defining features of the .NET runtime. It allows software engineers to allocate and use memory without having to worry about the lifetime of the allocations. This system prevents releasing memory too early and can reduce the occurrence of memory leaks.

The garbage collector enables this pleasant memory manage experience by automatically freeing memory when it is no longer in use. The .NET garbage collector is a tracing garbage collector, so it accomplishes this by finding all the memory that is currently in use. It starts its search for live memory in the call stacks of thread and static variables. It traces through the object graph, marking every live object. Once it has traversed the entire object graph, anything object that has not be marked is not in use and its memory can be reclaimed.

For cases that don't fit nicely into the stack and static variable scanning system, the GC provides an interface to create a "GC handle". A GC handle allows other parts of the .NET runtime and applications to explicitly extend the lifetime of a GC object, even if the object is not reachable from the normal locations the GC looks (call stacks and static variables).

This article describes the different types of GC handles implemented by CoreCLR's GC.

Public usable GC handle types

Since the beginning of .NET, 4 types of GC handles have been exposed to managed code through the GCHandle struct. The types are documented in the GCHandleType enum.

A GC handle can either be a strong or weak reference to an object. Strong handles (GCHandleType.Normal GCHandleType.Pinned) prevent the object from being collected. Weak handles (GCHandleType.Weak and GCHandleType.WeakTrackResurrection) allow the object to be collected. When using a weak handle, GCHandle.Target will return null after an object has been collected.

The WeakReference class is a wrapper around the weak versions of GC handles. It's finalizer will automatically clean up its underlying GC handle. 1

Starting with .NET 6, a fifth type was exposed to managed code through the DependentHandle struct.

No longer used handle types

Since .NET became open source, several features that were originally implemented in CoreCLR using special GC-handles in C++ were rewritten in C#. This is possible because C# has gained more low-level performance features. Additionally by re-writing features in C#, the other .NET runtime like NativeAOT and Mono can share their implementation.

Briefly, since these handle types don't matter in .NET 8 and beyond, here are unused handled types defined in gcinterface.h.

  • HNDTYPE_VARIABLE: a handle that can change between the 4 public handle types. According to CLR architect Jan Kotas, "the variable GC handles were used for WinRT/COM interop in .NET Native for UWP for a period of time".

  • HNDTYPE_ASYNCPINNED: previously used to implement the pinning behavior in the Pack method of System.Threading.Overlapped. Rewritten in C# in .NET 8.

  • HNDTYPE_SIZEDREF: never used in open source .NET to the best of my knowledge. It is used in .NET Framework for some caching APIs to estimate memory size of an object graph. See here and here.

  • HNDTYPE_WEAK_NATIVE_COM: This handle type was used the WeakReference class. It acts similarly to a normal weak handle, but it can store some extra data. If you put a COM object (specifically a runtime callable wrapper) into a WeakReference, and the COM object implement the WinRT interface IWeakReferenceSource, this handle type will be used to also store the IWeakReference returned by GetWeakReference. When the object is requested from the handle and the managed runtime-callable-wrapper has already been garbage collected, the runtime will attempt to recreate a RCW from the IWeakReference. This was rewritten in C# in .NET 8.

Reference-counted handle

The only handle type we have not yet mentioned is HNDTYPE_REFCOUNTED. This behaves as either a strong or weak handle. To determine whether the handle is strong, the GC asks the execution engine if the handle is strong or not. Specifically it calls the execution engine provided method RefCountedHandleCallbacks.

This simple but powerful interface is currently used by the interop systems in .NET. For COM interop, the VM can extend the lifetime of a managed object as long as the COM-callable-wrapper referencing it has a non-zero reference count. See Yi Zhang's article about ref-counted handles for more information about how CoreCLR uses them in COM interop.

For Objective-C interop, this type of handle is used to extend the lifetime of a managed object that has been exposed to Objective-C as long as its retain count is non-zero. The ability to create this type of handle is exposed through ObjectiveCMarshal.CreateReferenceTracking, but you probably will not need to call this API directly. The higher-level Objective-C interop system takes care of calling it for you.

An example of how ref-counted handles can be used in a .NET runtime

In the NativeAOT form factor of .NET, the user program is fully precompiled to native code. Compared to CoreCLR and Mono, much more of the runtime is implemented in C#. We can take advantage of this to write a concise examples of how ref-counted handles can be used.

Let's implement an interop system loosely based on the COM interop system in NativeAOT. It will implement the basics of lifetime management for the unmanaged wrapper only. The full code can be found in this GitHub repository.

NOTE: these code samples are missing some error handling and object resurrection is not handled. Also, these code samples use internal implementation details of NativeAOT that you should not rely upon in a regular application. See the .NET runtime source for a complete example of an interop system.

First we defined a ManagedObjectWrapper struct is will be allocated from unmanaged memory. The pointer to this struct will be passed between managed and unmanaged code. It keeps track of the unmanaged reference count. It also holds a ref-counted GC handle, which we will take about later. The MyAddRef and MyRelease functions allow unmanaged code to manipulate the reference count of the wrapper. The IsRooted property lets other parts of our interop system check to see if the unmanaged wrapper still has a positive reference count.

unsafe struct ManagedObjectWrapper
{
    internal GCHandle _holderHandle;
    internal int _refCount;

    public bool IsRooted => _refCount != 0;

    [UnmanagedCallersOnly(EntryPoint = "MyAddRef")]
    static int AddRef(nint nativeObj)
    {
        ManagedObjectWrapper* wrapper = (ManagedObjectWrapper*)nativeObj;
        return Interlocked.Increment(ref wrapper->_refCount);
    }

    [UnmanagedCallersOnly(EntryPoint = "MyRelease")]
    static int Release(nint nativeObj)
    {
        ManagedObjectWrapper* wrapper = (ManagedObjectWrapper*)nativeObj;
        return Interlocked.Decrement(ref wrapper->_refCount);
    }
}

The ref-counted GCHandle that the ManagedObjectWrapper holds points to a ManagedObjectWrapperHolder. This is a managed object that is used to store a reference to the wrapped object and manage the lifetime of the unmanaged ManagedObjectWrapper. The IsRootedCallback function is called by the garbage collector to check whether our object is still alive. As long as IsRootedCallback returns true, the holder object will be considered alive by the garbage collector. In turn since this holder object stores a strong reference to the wrapped object, the wrapped object is kept alive. This is how we can keep our managed object alive even if there is no managed reference to it.

Normally C# code can't execute while a garbage collection is running. Our ref-counted handle callback IsRootedCallback is an exception to this rule. While executing our callback we have to abide by a number of restrictions to avoid destabilizing the runtime, the most of obvious of which to not call back into garbage collector to allocate memory.

unsafe class ManagedObjectWrapperHolder
{
    static ManagedObjectWrapperHolder()
    {
        delegate* unmanaged<IntPtr, bool> callback = &IsRootedCallback;
        RuntimeImports.RhRegisterRefCountedHandleCallback((nint)callback, MethodTable.Of<ManagedObjectWrapperHolder>());
    }

    [UnmanagedCallersOnly]
    static bool IsRootedCallback(IntPtr pObj)
    {
        // We are paused in the GC, so this is safe.
        ManagedObjectWrapperHolder* holder = (ManagedObjectWrapperHolder*)&pObj;
        return holder->_wrapper->IsRooted;
    }

    internal ManagedObjectWrapper* _wrapper;
    internal readonly object _wrappedObject;

    public ManagedObjectWrapperHolder(ManagedObjectWrapper* wrapper, object wrappedObject)
    {
        _wrapper = wrapper;
        _wrappedObject = wrappedObject;
        _wrapper->_holderHandle = GCHandle.FromIntPtr(RuntimeImports.RhHandleAllocRefCounted(this));
    }

    internal int AddRef()
    {
        return Interlocked.Increment(ref _wrapper->_refCount);
    }

    ~ManagedObjectWrapperHolder()
    {
        _wrapper->_holderHandle.Free();
        NativeMemory.Free(_wrapper);
    }
}

The SimpleInteropSystem class exposes the ability to get the unmanaged wrapper for a class so that it can be passed to native code. It also exposes the ability to get back the managed object from the generated wrapper. The ConditionalWeakTable ensures that as long as the managed object is kept alive with a strong reference, the garbage collect will keep the holder object alive. And as long as our holder object is kept alive, it's finalizer will not run and will not free the unmanaged wrapper. 2

public static unsafe class SimpleInteropSystem
{
    private static readonly ConditionalWeakTable<object, ManagedObjectWrapperHolder> s_objects = new();

    public static nint GetNativeObject(object obj)
    {
        ManagedObjectWrapperHolder holder = s_objects.GetValue(obj, static key => {
            ManagedObjectWrapper* wrapper = (ManagedObjectWrapper*)NativeMemory.AllocZeroed((nuint)sizeof(ManagedObjectWrapper));
            return new ManagedObjectWrapperHolder(wrapper, key);
        });
        holder.AddRef();
        return (nint)holder._wrapper;
    }

    public static object? GetManagedObject(nint nativeObj)
    {
        if (nativeObj == 0)
            throw new ArgumentNullException();
        ManagedObjectWrapper* wrapper = (ManagedObjectWrapper*)nativeObj;
        return ((ManagedObjectWrapperHolder)wrapper->_holderHandle.Target!)._wrappedObject;
    }
}

Now we can demonstrate how to use our simple interop system. We will use C to define an unmanaged function that we will pass our wrapper to. It will call back into managed code and then decrement the reference count on the unmanaged wrapper:

int32_t MyAddRef(void* obj);
int32_t MyRelease(void* obj);
int32_t GetObjectInfo(void* obj);

void MyUnmanagedFunction(void* obj)
{
    int32_t info = GetObjectInfo(obj);
    printf("native info: %d\n", info);
    MyRelease(obj);
}

On the C# side, we will create the our managed object and get the unmanaged wrapper for it. We pass ownership of the unmanaged wrapper to MyUnmanagedFunction, which will take care of calling MyRelease. We then trigger some garbage collections, which should clean check our ref counted handle, notice that the ref count is zero, and then finalize the holder object. This will free the

static unsafe partial class Program
{
    static void Main()
    {
        nint nativeObj = CreateAndMarshalObject();

        // Trigger a collection while the ref count is still above 0.
        // The object should not be collected.
        GC.Collect();

        // We pass ownership to the the unmanaged side. It will take care of releasing.
        MyUnmanagedFunction(nativeObj);
        nativeObj = 0;

        // Clean up the ref-counted handle
        for (int i = 0; i < 10; i++)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }
    }

    // no inlining to make sure the lifetime of `obj` ends at the end of this function.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static nint CreateAndMarshalObject()
    {
        var obj = new MyInteropObject(42);
        return SimpleInteropSystem.GetNativeObject(obj);
    }

    [LibraryImport("*")]
    private static partial void MyUnmanagedFunction(nint nativeObj);
}

If you use your debugger to put a breakpoint in the IsRootedCallback, you can see the GC calling back into your code.

>	ref-counted.exe!ref_counted_ManagedObjectWrapperHolder__IsRootedCallback()
 	ref-counted.exe!RestrictedCallouts::InvokeRefCountedHandleCallbacks(Object * pObject)
 	ref-counted.exe!PromoteRefCounted(Object * * pObjRef, unsigned __int64 * pExtraInfo, unsigned __int64 lp1, unsigned __int64 lp2)
 	[Inline Frame] ref-counted.exe!ScanConsecutiveHandlesWithoutUserData(Object * * pValue, Object * *)
 	ref-counted.exe!BlockScanBlocksWithoutUserData(TableSegment * pSegment, unsigned int uBlock, unsigned int uCount, ScanCallbackInfo * pInfo)
 	[Inline Frame] ref-counted.exe!SegmentScanByTypeChain(TableSegment *)
 	ref-counted.exe!TableScanHandles(HandleTable * pTable, const unsigned int * puType, unsigned int uTypeCount, TableSegment *(*)(HandleTable *, TableSegment *, CrstHolderWithState *) pfnSegmentIterator, void(*)(TableSegment *, unsigned int, unsigned int, ScanCallbackInfo *) pfnBlockHandler, ScanCallbackInfo * pInfo, CrstHolderWithState * pCrstHolder)
 	ref-counted.exe!HndScanHandlesForGC(HandleTable * hTable, void(*)(Object * *, unsigned __int64 *, unsigned __int64, unsigned __int64) scanProc, unsigned __int64 param1, unsigned __int64 param2, const unsigned int * types, unsigned int typeCount, unsigned int condemned, unsigned int maxgen, unsigned int flags)
 	ref-counted.exe!Ref_TraceNormalRoots(unsigned int condemned, unsigned int maxgen, ScanContext * sc, void(*)(Object * *, ScanContext *, unsigned int) fn)
 	ref-counted.exe!GCScan::GcScanHandles(void(*)(Object * *, ScanContext *, unsigned int) fn, int condemned, int max_gen, ScanContext * sc)
 	ref-counted.exe!WKS::gc_heap::mark_phase(int condemned_gen_number, int mark_only_p)
 	ref-counted.exe!WKS::gc_heap::gc1()
 	ref-counted.exe!WKS::gc_heap::garbage_collect(int n)
 	ref-counted.exe!WKS::GCHeap::GarbageCollectGeneration(unsigned int gen, gc_reason reason)
 	[Inline Frame] ref-counted.exe!WKS::GCHeap::GarbageCollectTry(int)
 	ref-counted.exe!WKS::GCHeap::GarbageCollect(int generation, bool low_memory_p, int mode)
 	ref-counted.exe!RhpCollect(unsigned int uGeneration, unsigned int uMode, unsigned int lowMemoryP)
 	ref-counted.exe!S_P_CoreLib_System_Runtime_InternalCalls__RhpCollect()	Unknown
 	ref-counted.exe!S_P_CoreLib_System_Runtime_InternalCalls__RhCollect()
 	ref-counted.exe!S_P_CoreLib_System_GC__Collect_0()
 	ref-counted.exe!ref_counted_Program__Main()
 	ref-counted.exe!ref_counted__Module___MainMethodWrapper()	Unknown
 	ref-counted.exe!ref_counted__Module___StartupCodeMain()	Unknown
 	ref-counted.exe!wmain(int argc, wchar_t * * argv)

For more examples of how to use ref counted handles, see the pull requests where I used them for Objective-C interop and COM interop in Native AOT.

Conclusion

Of the ten different types of GC handles that currently exist in the .NET garbage collector, I think the ref-counted handle is the most fun. It's flexible design allows it to be used by a number of interop systems in the .NET runtime.

Footnotes

1

Fun fact: CoreCLR treats WeakReference specially and cleans up it's GC handle as soon as the WeakReference is discovered to be no longer reachable, bypassing the normal finalizer queue. See here and here.

2

This is the part of this toy example that does not handle object resurrection properly. Object resurrection is the dramatic name for an object becoming strongly reachable again after entering the finalizer queue. See this PR for an example of how NativeAOT fixes the object resurrection problem for ManagedObjectWrapper.