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 of 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 that the GC looks at (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. By re-writing features in C#, the other .NET runtimes 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 thePack
method ofSystem.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 by theWeakReference
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 aWeakReference
, and the COM object implement the WinRT interfaceIWeakReferenceSource
, this handle type will be used to also store theIWeakReference
returned byGetWeakReference
. 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 theIWeakReference
. 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 AddRef
and Release
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
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.
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
.