From a50b8c73c0727e846312a013ac9597af61725897 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Fri, 7 Aug 2026 21:20:28 -0700 Subject: [PATCH 1/6] Partition the ComWrappers RCW cache into per-processor buckets The RCW cache is consulted on essentially every native to managed transition, and the finalizer thread concurrently takes write locks on it to remove entries for collected RCWs. With a single dictionary behind a single reader-writer lock, all of that traffic serializes on one lock, which shows up as the dominant cost in WinRT interop profiles. Split the cache into independent buckets, each holding the same dictionary and reader-writer lock as before, and select the bucket for a given COM instance by hashing its pointer. The number of buckets matches the processor count (rounded up to a power of two), the same default concurrency level used by ConcurrentDictionary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/InteropServices/ComWrappers.cs | 314 +++++++++++------- .../Interop/COM/ComWrappers/API/Program.cs | 40 ++- 2 files changed, 226 insertions(+), 128 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index 551f84fc1ec3e5..e36aa8ad8e51cc 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Threading; @@ -1296,10 +1297,56 @@ internal void RemoveWrappersFromCache(IEnumerable wrappers) _rcwCache.RemoveAll(wrappers); } - private sealed class RcwCache + /// + /// The cache mapping COM instances to the objects tracking their RCWs. + /// + /// + /// The cache is partitioned into several independent buckets, each with its own lock, so that operations on + /// COM instances that map to different buckets don't contend with one another. Reducing that contention is + /// important because the cache is consulted on essentially every transition from native to managed code, and + /// because the finalizer thread concurrently takes write locks to remove entries for collected RCWs. + /// + private readonly struct RcwCache { - private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); - private readonly Dictionary _cache = []; + private readonly Bucket[] _buckets; + + public RcwCache() + { + // Use as many buckets as there are processors, matching the default concurrency level of + // 'ConcurrentDictionary'. The count is rounded up to a power of two so that the bucket for a + // given COM instance can be selected with a mask rather than a division. + int bucketCount = (int)BitOperations.RoundUpToPowerOf2((uint)Environment.ProcessorCount); + Bucket[] buckets = new Bucket[bucketCount]; + + for (int i = 0; i < buckets.Length; i++) + { + buckets[i] = new Bucket(); + } + + _buckets = buckets; + } + + /// + /// Gets the bucket owning the entries for a given COM instance. + /// + /// The com instance to get the bucket for. + /// The bucket owning the entries for . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private ref readonly Bucket GetBucket(IntPtr comPointer) + { + Bucket[] buckets = _buckets; + + // COM instances are heap allocated, so they're always at least pointer aligned (and 16-byte aligned + // in practice). That means their low bits are constant and can't be used to select a bucket directly. + // Multiplying by a large odd constant (2^64 divided by the golden ratio) mixes every input bit into + // the high half of the product, which is then masked to produce the index. The whole sequence lowers + // to a multiply, a shift and a mask, which is negligible next to the lookup that follows. + ulong hash = (ulong)(nuint)comPointer * 0x9E3779B97F4A7C15; + uint index = (uint)(hash >> 32) & (uint)(buckets.Length - 1); + + // Return the bucket by reference, so that it's addressed in place in the array rather than copied + return ref buckets[index]; + } /// /// Gets the current RCW proxy object for if it exists in the cache or inserts a new entry with . @@ -1310,139 +1357,182 @@ private sealed class RcwCache /// The proxy object currently in the cache for or the proxy object owned by if no entry exists and the corresponding native wrapper. public (NativeObjectWrapper actualWrapper, object actualProxy) GetOrAddProxyForComInstance(IntPtr comPointer, NativeObjectWrapper wrapper, object comProxy) { - _lock.EnterWriteLock(); - try - { - Debug.Assert(wrapper.ProxyHandle.Target == comProxy); - ref GCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); - if (!exists) - { - // Someone else didn't beat us to adding the entry to the cache. - // Add our entry here. - rcwEntry = GCHandle.Alloc(wrapper, GCHandleType.Weak); - } - else if (rcwEntry.Target is not (NativeObjectWrapper cachedWrapper)) - { - Debug.Assert(rcwEntry.IsAllocated); - // The target was collected, so we need to update the cache entry. - rcwEntry.Target = wrapper; - } - else - { - object? existingProxy = cachedWrapper.ProxyHandle.Target; - // The target NativeObjectWrapper was not collected, but we need to make sure - // that the proxy object is still alive. - if (existingProxy is not null) - { - // The existing proxy object is still alive, we will use that. - return (cachedWrapper, existingProxy); - } + return GetBucket(comPointer).GetOrAddProxyForComInstance(comPointer, wrapper, comProxy); + } - // The proxy object was collected, so we need to update the cache entry. - rcwEntry.Target = wrapper; - } + /// + /// Gets the current RCW proxy object for , if it exists in the cache and is still alive. + /// + /// The com instance we want to get the RCW for. + /// The proxy object currently in the cache for , if any. + public object? FindProxyForComInstance(IntPtr comPointer) + { + return GetBucket(comPointer).FindProxyForComInstance(comPointer); + } - // We either added an entry to the cache or updated an existing entry that was dead. - // Return our target object. - return (wrapper, comProxy); - } - finally + /// + /// Removes the entry associating with , if present. + /// + /// The com instance to remove the entry for. + /// The that is expected to be in the cache. + public void Remove(IntPtr comPointer, NativeObjectWrapper wrapper) + { + GetBucket(comPointer).Remove(comPointer, wrapper); + } + + /// + /// Removes the entries for all input objects, if present. + /// + /// The objects to remove the entries for. + public void RemoveAll(IEnumerable wrappers) + { + // The wrappers can span multiple buckets, so they're removed one at a time. This is only used when + // tearing down an apartment, so the extra lock acquisitions don't matter. Note that entries are not + // removed atomically as a batch anymore, but that was never something callers could rely on: the + // cache is only ever observed one entry at a time. + foreach (NativeObjectWrapper wrapper in wrappers) { - _lock.ExitWriteLock(); + IntPtr comPointer = wrapper.ExternalComObject; + + GetBucket(comPointer).Remove(comPointer, wrapper); } } - public object? FindProxyForComInstance(IntPtr comPointer) + /// + /// A single partition of the RCW cache, holding the entries for all COM instances that map to it. + /// + /// + /// This is a struct so that buckets are stored inline in the containing array. That saves a dereference + /// on each lookup, and lets several buckets share a cache line. There is no false sharing to worry about, + /// as the fields are only ever read: all mutable state lives in the referenced lock and dictionary. + /// + private readonly struct Bucket { - _lock.EnterReadLock(); - try + private readonly ReaderWriterLockSlim _lock; + private readonly Dictionary _cache; + + public Bucket() + { + _lock = new ReaderWriterLockSlim(); + _cache = []; + } + + /// + public (NativeObjectWrapper actualWrapper, object actualProxy) GetOrAddProxyForComInstance(IntPtr comPointer, NativeObjectWrapper wrapper, object comProxy) { - if (!_cache.TryGetValue(comPointer, out GCHandle existingHandle)) + _lock.EnterWriteLock(); + try { - // No entry in the cache. - return null; + Debug.Assert(wrapper.ProxyHandle.Target == comProxy); + ref GCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); + if (!exists) + { + // Someone else didn't beat us to adding the entry to the cache. + // Add our entry here. + rcwEntry = GCHandle.Alloc(wrapper, GCHandleType.Weak); + } + else if (rcwEntry.Target is not (NativeObjectWrapper cachedWrapper)) + { + Debug.Assert(rcwEntry.IsAllocated); + // The target was collected, so we need to update the cache entry. + rcwEntry.Target = wrapper; + } + else + { + object? existingProxy = cachedWrapper.ProxyHandle.Target; + // The target NativeObjectWrapper was not collected, but we need to make sure + // that the proxy object is still alive. + if (existingProxy is not null) + { + // The existing proxy object is still alive, we will use that. + return (cachedWrapper, existingProxy); + } + + // The proxy object was collected, so we need to update the cache entry. + rcwEntry.Target = wrapper; + } + + // We either added an entry to the cache or updated an existing entry that was dead. + // Return our target object. + return (wrapper, comProxy); } - if (existingHandle.Target is NativeObjectWrapper { ProxyHandle.Target: object cachedProxy }) + finally { - // The target exists and is still alive. Return it. - return cachedProxy; + _lock.ExitWriteLock(); } - // The target was collected, so we need to remove the entry from the cache. - // We'll do this in a write lock after we exit the read lock. - // We don't use an upgradeable lock here as only one thread can hold an upgradeable lock at a time, - // effectively eliminating the benefit of using a reader-writer lock. - } - finally - { - _lock.ExitReadLock(); } - _lock.EnterWriteLock(); - try + /// + public object? FindProxyForComInstance(IntPtr comPointer) { - // Someone else could have removed the entry or added a new one in the time - // between us releasing the read lock and acquiring the write lock. - if (_cache.TryGetValue(comPointer, out GCHandle existingHandle) - && existingHandle.Target is null) + _lock.EnterReadLock(); + try { - // There's still a dead entry in the cache, - // remove it. - _cache.Remove(comPointer); - existingHandle.Free(); + if (!_cache.TryGetValue(comPointer, out GCHandle existingHandle)) + { + // No entry in the cache. + return null; + } + if (existingHandle.Target is NativeObjectWrapper { ProxyHandle.Target: object cachedProxy }) + { + // The target exists and is still alive. Return it. + return cachedProxy; + } + // The target was collected, so we need to remove the entry from the cache. + // We'll do this in a write lock after we exit the read lock. + // We don't use an upgradeable lock here as only one thread can hold an upgradeable lock at a time, + // effectively eliminating the benefit of using a reader-writer lock. + } + finally + { + _lock.ExitReadLock(); } - } - finally - { - _lock.ExitWriteLock(); - } - return null; - } + _lock.EnterWriteLock(); + try + { + // Someone else could have removed the entry or added a new one in the time + // between us releasing the read lock and acquiring the write lock. + if (_cache.TryGetValue(comPointer, out GCHandle existingHandle) + && existingHandle.Target is null) + { + // There's still a dead entry in the cache, + // remove it. + _cache.Remove(comPointer); + existingHandle.Free(); + } + } + finally + { + _lock.ExitWriteLock(); + } - public void Remove(IntPtr comPointer, NativeObjectWrapper wrapper) - { - _lock.EnterWriteLock(); - try - { - Remove_Locked(comPointer, wrapper); - } - finally - { - _lock.ExitWriteLock(); + return null; } - } - public void RemoveAll(IEnumerable wrappers) - { - _lock.EnterWriteLock(); - try + /// + public void Remove(IntPtr comPointer, NativeObjectWrapper wrapper) { - foreach (NativeObjectWrapper wrapper in wrappers) + _lock.EnterWriteLock(); + try { - Remove_Locked(wrapper.ExternalComObject, wrapper); + // TryGetOrCreateObjectForComInstanceInternal may have put a new entry into the cache + // in the time between the GC cleared the contents of the GC handle but before the + // NativeObjectWrapper finalizer ran. + // Only remove the entry if the target of the GC handle is the NativeObjectWrapper + // or is null (indicating that the corresponding NativeObjectWrapper has been scheduled for finalization). + if (_cache.TryGetValue(comPointer, out GCHandle cachedRef) + && (wrapper == cachedRef.Target + || cachedRef.Target is null)) + { + _cache.Remove(comPointer); + cachedRef.Free(); + } + } + finally + { + _lock.ExitWriteLock(); } - } - finally - { - _lock.ExitWriteLock(); - } - } - - private void Remove_Locked(IntPtr comPointer, NativeObjectWrapper wrapper) - { - Debug.Assert(_lock.IsWriteLockHeld); - // This method is used in a scenario where we already have a lock on the cache, so we can skip acquiring the lock again. - // TryGetOrCreateObjectForComInstanceInternal may have put a new entry into the cache - // in the time between the GC cleared the contents of the GC handle but before the - // NativeObjectWrapper finalizer ran. - // Only remove the entry if the target of the GC handle is the NativeObjectWrapper - // or is null (indicating that the corresponding NativeObjectWrapper has been scheduled for finalization). - if (_cache.TryGetValue(comPointer, out GCHandle cachedRef) - && (wrapper == cachedRef.Target - || cachedRef.Target is null)) - { - _cache.Remove(comPointer); - cachedRef.Free(); } } } diff --git a/src/tests/Interop/COM/ComWrappers/API/Program.cs b/src/tests/Interop/COM/ComWrappers/API/Program.cs index da49300f166e61..4de682ea3e79f1 100644 --- a/src/tests/Interop/COM/ComWrappers/API/Program.cs +++ b/src/tests/Interop/COM/ComWrappers/API/Program.cs @@ -1083,43 +1083,51 @@ public void ComWrappersNoLockAroundQueryInterface() Console.WriteLine($"Running {nameof(ComWrappersNoLockAroundQueryInterface)}..."); var cw = new RecursiveSimpleComWrappers(); + var managedObject = new RecursiveCrossThreadQI(cw); - IntPtr comObject = cw.GetOrCreateComInterfaceForObject(new RecursiveCrossThreadQI(cw), CreateComInterfaceFlags.None); + IntPtr comObject = cw.GetOrCreateComInterfaceForObject(managedObject, CreateComInterfaceFlags.None); try { + // The nested call has to use this same COM instance. The RCW cache is partitioned into buckets + // keyed off the COM instance, so using a different instance would only exercise the same lock by + // chance, and the test would no longer reliably catch a regression. + managedObject.NestedComObject = comObject; + _ = cw.GetOrCreateObjectForComInstance(comObject, CreateObjectFlags.TrackerObject); } finally { Marshal.Release(comObject); } + + Assert.True(managedObject.NestedCallCompleted); } - private class RecursiveCrossThreadQI(ComWrappers? wrappers) : ICustomQueryInterface + private class RecursiveCrossThreadQI(ComWrappers wrappers) : ICustomQueryInterface { + public IntPtr NestedComObject { get; set; } + + public bool NestedCallCompleted { get; private set; } + CustomQueryInterfaceResult ICustomQueryInterface.GetInterface(ref Guid iid, out IntPtr ppv) { ppv = IntPtr.Zero; - if (iid == ComWrappersHelper.IID_IReferenceTracker && wrappers is not null) + if (iid == ComWrappersHelper.IID_IReferenceTracker) { Console.WriteLine("Attempting to create a new COM object on a different thread."); + IntPtr nestedComObject = NestedComObject; Thread thread = new Thread(() => { - IntPtr comObject = wrappers.GetOrCreateComInterfaceForObject(new RecursiveCrossThreadQI(null), CreateComInterfaceFlags.None); - try - { - // Make sure that ComWrappers isn't locking in GetOrCreateObjectForComInstance - // around the QI call by calling it on a different thread from within a QI call to register a new managed wrapper - // for a COM object representing "this". - _ = wrappers.GetOrCreateObjectForComInstance(comObject, CreateObjectFlags.None); - } - finally - { - Marshal.Release(comObject); - } + // Make sure that ComWrappers isn't locking in GetOrCreateObjectForComInstance + // around the QI call by calling it on a different thread from within a QI call to register a new managed wrapper + // for a COM object representing "this". + _ = wrappers.GetOrCreateObjectForComInstance(nestedComObject, CreateObjectFlags.None); }); thread.Start(); - thread.Join(TimeSpan.FromSeconds(20)); // 20 seconds should be more than long enough for the thread to complete + + // The result is recorded and asserted by the caller, rather than asserted here, as this + // callback is invoked through the COM ABI, which a managed exception can't propagate through. + NestedCallCompleted = thread.Join(TimeSpan.FromSeconds(20)); // 20 seconds should be more than long enough for the thread to complete } return CustomQueryInterfaceResult.Failed; From 2074c0d02db7e0d013511a197c98923aefa0d0b9 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Fri, 7 Aug 2026 21:29:36 -0700 Subject: [PATCH 2/6] Use WeakGCHandle in the RCW cache The cached handles always point to a NativeObjectWrapper, so a strongly typed weak handle expresses that directly. It skips the type check on every read, allocates through GCHandle.InternalAlloc without revalidating the handle type, and reads the target once instead of twice when checking whether it was collected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/InteropServices/ComWrappers.cs | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index e36aa8ad8e51cc..35a67ea79f0a30 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -1409,7 +1409,7 @@ public void RemoveAll(IEnumerable wrappers) private readonly struct Bucket { private readonly ReaderWriterLockSlim _lock; - private readonly Dictionary _cache; + private readonly Dictionary> _cache; public Bucket() { @@ -1424,18 +1424,18 @@ public Bucket() try { Debug.Assert(wrapper.ProxyHandle.Target == comProxy); - ref GCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); + ref WeakGCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); if (!exists) { // Someone else didn't beat us to adding the entry to the cache. // Add our entry here. - rcwEntry = GCHandle.Alloc(wrapper, GCHandleType.Weak); + rcwEntry = new WeakGCHandle(wrapper); } - else if (rcwEntry.Target is not (NativeObjectWrapper cachedWrapper)) + else if (!rcwEntry.TryGetTarget(out NativeObjectWrapper? cachedWrapper)) { Debug.Assert(rcwEntry.IsAllocated); // The target was collected, so we need to update the cache entry. - rcwEntry.Target = wrapper; + rcwEntry.SetTarget(wrapper); } else { @@ -1449,7 +1449,7 @@ public Bucket() } // The proxy object was collected, so we need to update the cache entry. - rcwEntry.Target = wrapper; + rcwEntry.SetTarget(wrapper); } // We either added an entry to the cache or updated an existing entry that was dead. @@ -1468,12 +1468,13 @@ public Bucket() _lock.EnterReadLock(); try { - if (!_cache.TryGetValue(comPointer, out GCHandle existingHandle)) + if (!_cache.TryGetValue(comPointer, out WeakGCHandle existingHandle)) { // No entry in the cache. return null; } - if (existingHandle.Target is NativeObjectWrapper { ProxyHandle.Target: object cachedProxy }) + if (existingHandle.TryGetTarget(out NativeObjectWrapper? cachedWrapper) + && cachedWrapper.ProxyHandle.Target is object cachedProxy) { // The target exists and is still alive. Return it. return cachedProxy; @@ -1493,13 +1494,13 @@ public Bucket() { // Someone else could have removed the entry or added a new one in the time // between us releasing the read lock and acquiring the write lock. - if (_cache.TryGetValue(comPointer, out GCHandle existingHandle) - && existingHandle.Target is null) + if (_cache.TryGetValue(comPointer, out WeakGCHandle existingHandle) + && !existingHandle.TryGetTarget(out _)) { // There's still a dead entry in the cache, // remove it. _cache.Remove(comPointer); - existingHandle.Free(); + existingHandle.Dispose(); } } finally @@ -1521,12 +1522,12 @@ public void Remove(IntPtr comPointer, NativeObjectWrapper wrapper) // NativeObjectWrapper finalizer ran. // Only remove the entry if the target of the GC handle is the NativeObjectWrapper // or is null (indicating that the corresponding NativeObjectWrapper has been scheduled for finalization). - if (_cache.TryGetValue(comPointer, out GCHandle cachedRef) - && (wrapper == cachedRef.Target - || cachedRef.Target is null)) + if (_cache.TryGetValue(comPointer, out WeakGCHandle cachedRef) + && (!cachedRef.TryGetTarget(out NativeObjectWrapper? cachedWrapper) + || cachedWrapper == wrapper)) { _cache.Remove(comPointer); - cachedRef.Free(); + cachedRef.Dispose(); } } finally From 56f43bb6e63f4f91e253056c51d3db88917ad6a2 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 00:46:39 -0700 Subject: [PATCH 3/6] Skip the resurrection tracking handle for RCWs without a finalizer Every RCW allocates two weak GC handles for its proxy: a plain weak one, and a second 'WeakTrackResurrection' one that exists only so the NativeObjectWrapper is cleaned up after the proxy's finalizer has run (the proxy's finalizer may access the native object). If the proxy's type declares no finalizer, it can never observe the native object once it becomes unreachable, and it can never be resurrected, so the second handle would always be cleared at the same time as the first. Detect that case from the MethodTable and skip allocating it. This is worth doing because allocating, clearing and freeing GC handles is a substantial part of the cost of every RCW, and resurrection tracking handles are more expensive for the GC to process than plain weak ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompilerServices/RuntimeHelpers.CoreCLR.cs | 8 ++++++++ .../CompilerServices/RuntimeHelpers.NativeAot.cs | 8 ++++++++ .../System/Runtime/InteropServices/ComWrappers.cs | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs index d4c2e93237d078..94683cea769356 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs @@ -449,6 +449,14 @@ internal static unsafe bool ObjectHasComponentSize(object obj) return GetMethodTable(obj)->HasComponentSize; } + // Returns true iff the type of the object declares a finalizer. + // Callers are required to keep obj alive + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static unsafe bool ObjectHasFinalizer(object obj) + { + return GetMethodTable(obj)->HasFinalizer; + } + /// /// Boxes a given value using an input to determine its type. /// diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs index 487c460e1c1b23..4f392bf2bac552 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs @@ -213,6 +213,14 @@ internal static unsafe bool ObjectHasComponentSize(object obj) return GetMethodTable(obj)->HasComponentSize; } + // Returns true iff the type of the object declares a finalizer. + // Callers are required to keep obj alive + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static unsafe bool ObjectHasFinalizer(object obj) + { + return GetMethodTable(obj)->IsFinalizable; + } + public static void PrepareMethod(RuntimeMethodHandle method) { if (method.Value == IntPtr.Zero) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index 35a67ea79f0a30..d030582943b1eb 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -601,7 +601,20 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper // due to it can access the native object in the finalizer. At the same time, // we want other callers which are using ProxyHandle such as the reference tracker runtime // to see the object as not alive once it is eligible for finalization. - _proxyHandleTrackingResurrection = GCHandle.Alloc(comProxy, GCHandleType.WeakTrackResurrection); + // + // If the RCW has no finalizer, it can never observe the native object past the point + // where it becomes unreachable, and it can never be resurrected. The extra handle would + // therefore always be cleared at the same time as the one above, so we skip allocating it. + // This matters because allocating, clearing and freeing GC handles is a substantial part + // of the cost of every RCW, and resurrection tracking handles are also more expensive for + // the GC to process than plain weak handles. + if (RuntimeHelpers.ObjectHasFinalizer(comProxy)) + { + _proxyHandleTrackingResurrection = GCHandle.Alloc(comProxy, GCHandleType.WeakTrackResurrection); + } + + // 'ObjectHasFinalizer' reads the MethodTable, which requires the object to be kept alive + GC.KeepAlive(comProxy); // If this is an aggregation scenario and the identity object // is a managed object wrapper, we need to call Release() to From b6e44e6b7279516da5aa289ed76c5eb553ac38ad Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 00:52:17 -0700 Subject: [PATCH 4/6] Use WeakGCHandle for the resurrection tracking handle The handle always tracks the proxy object, so a strongly typed weak handle expresses that directly: it allocates through GCHandle.InternalAlloc without revalidating the handle type, and skips the cast when reading the target. Only this handle is converted. The '_proxyHandle' field and the handles in 'GCHandleSet' are mirrored in native code (see 'NativeObjectWrapperObject' in interoplibinterface_comwrappers.h, read via 'GetProxyHandle') and flow through the on-stack COM struct used for reference tracker callbacks, so converting those is a wider change that needs to be validated against the native side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Runtime/InteropServices/ComWrappers.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index d030582943b1eb..a638e0a1c43998 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -549,7 +549,7 @@ internal unsafe class NativeObjectWrapper private IntPtr _externalComObject; private IntPtr _inner; private GCHandle _proxyHandle; - private GCHandle _proxyHandleTrackingResurrection; + private WeakGCHandle _proxyHandleTrackingResurrection; private readonly bool _aggregatedManagedObjectWrapper; private readonly bool _uniqueInstance; @@ -610,7 +610,7 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper // the GC to process than plain weak handles. if (RuntimeHelpers.ObjectHasFinalizer(comProxy)) { - _proxyHandleTrackingResurrection = GCHandle.Alloc(comProxy, GCHandleType.WeakTrackResurrection); + _proxyHandleTrackingResurrection = new WeakGCHandle(comProxy, trackResurrection: true); } // 'ObjectHasFinalizer' reads the MethodTable, which requires the object to be kept alive @@ -649,7 +649,7 @@ public virtual void Release() if (_proxyHandleTrackingResurrection.IsAllocated) { - _proxyHandleTrackingResurrection.Free(); + _proxyHandleTrackingResurrection.Dispose(); } // If the inner was supplied, we need to release our reference. @@ -664,7 +664,7 @@ public virtual void Release() ~NativeObjectWrapper() { - if (_proxyHandleTrackingResurrection.IsAllocated && _proxyHandleTrackingResurrection.Target != null) + if (_proxyHandleTrackingResurrection.IsAllocated && _proxyHandleTrackingResurrection.TryGetTarget(out _)) { // The RCW object has not been fully collected, so it still // can make calls on the native object in its finalizer. From 610a1b63c5c06964342851fe8e20e7063fbedc67 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 00:57:13 -0700 Subject: [PATCH 5/6] Use WeakGCHandle for the RCW proxy handle Both handles tracking the proxy object are now strongly typed weak handles. They allocate through GCHandle.InternalAlloc without revalidating the handle type, and skip the cast when reading the target. The layout is unchanged, so the native mirror of 'NativeObjectWrapper' still matches: 'GCHandle' only alters the stored value for pinned handles, so a weak handle holds the raw handle in both representations. This also applies to the on-stack COM struct used for reference tracker callbacks, which passes the handle straight through to native code. The redundant 'IsAllocated' checks around the disposal calls are also dropped, as 'WeakGCHandle.Dispose' already handles a default handle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrackerObjectManager.NativeAot.cs | 8 +++--- .../Runtime/InteropServices/ComWrappers.cs | 26 +++++++------------ .../InteropServices/TrackerObjectManager.cs | 2 +- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs index 7bc461cef8d54c..db592b2f76606c 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs @@ -186,7 +186,7 @@ internal static void DetachNonPromotedObjects() ReferenceTrackerNativeObjectWrapper? nativeObjectWrapper = Unsafe.As(weakNativeObjectWrapperHandle.Target); if (nativeObjectWrapper != null && nativeObjectWrapper.TrackerObject != IntPtr.Zero && - !RuntimeImports.RhIsPromoted(nativeObjectWrapper.ProxyHandle.Target)) + !RuntimeImports.RhIsPromoted(nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null)) { // Notify the wrapper it was not promoted and is being collected. BeforeWrapperFinalized(nativeObjectWrapper.TrackerObject); @@ -205,9 +205,9 @@ internal static unsafe class FindReferenceTargetsCallback internal ref struct Instance { private readonly IntPtr _vtable; // First field is IUnknown based vtable. - public GCHandle RootObject; + public WeakGCHandle RootObject; - public Instance(GCHandle handle) + public Instance(WeakGCHandle handle) { _vtable = (IntPtr)Unsafe.AsPointer(in FindReferenceTargetsCallback.Vftbl); RootObject = handle; @@ -240,7 +240,7 @@ private static unsafe int IFindReferenceTargetsCallback_FoundTrackerTarget(IntPt return HResults.E_POINTER; } - object sourceObject = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.Target!; + _ = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.TryGetTarget(out object? sourceObject); if (!TryGetObject(referenceTrackerTarget, out object? targetObject)) { diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index a638e0a1c43998..51880b5185f619 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -548,7 +548,7 @@ internal unsafe class NativeObjectWrapper private ComWrappers _comWrappers; private IntPtr _externalComObject; private IntPtr _inner; - private GCHandle _proxyHandle; + private WeakGCHandle _proxyHandle; private WeakGCHandle _proxyHandleTrackingResurrection; private readonly bool _aggregatedManagedObjectWrapper; private readonly bool _uniqueInstance; @@ -594,7 +594,7 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper _inner = inner; _comWrappers = comWrappers; _uniqueInstance = flags.HasFlag(CreateObjectFlags.UniqueInstance); - _proxyHandle = GCHandle.Alloc(comProxy, GCHandleType.Weak); + _proxyHandle = new WeakGCHandle(comProxy); // We have a separate handle tracking resurrection as we want to make sure // we clean up the NativeObjectWrapper only after the RCW has been finalized @@ -630,7 +630,7 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper internal IntPtr ExternalComObject => _externalComObject; internal ComWrappers ComWrappers => _comWrappers; - internal GCHandle ProxyHandle => _proxyHandle; + internal WeakGCHandle ProxyHandle => _proxyHandle; internal bool IsUniqueInstance => _uniqueInstance; internal bool IsAggregatedWithManagedObjectWrapper => _aggregatedManagedObjectWrapper; @@ -642,15 +642,8 @@ public virtual void Release() _comWrappers = null!; } - if (_proxyHandle.IsAllocated) - { - _proxyHandle.Free(); - } - - if (_proxyHandleTrackingResurrection.IsAllocated) - { - _proxyHandleTrackingResurrection.Dispose(); - } + _proxyHandle.Dispose(); + _proxyHandleTrackingResurrection.Dispose(); // If the inner was supplied, we need to release our reference. if (_inner != IntPtr.Zero) @@ -1274,7 +1267,7 @@ private void RegisterWrapperForObject(NativeObjectWrapper wrapper, object comPro // for the same COM instance, but in that case we'll be passed the same NativeObjectWrapper instance // for both threads. In that case, it doesn't matter which thread adds the entry to the NativeObjectWrapper table // as the entry is always the same pair. - Debug.Assert(wrapper.ProxyHandle.Target == comProxy); + Debug.Assert(wrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && proxyTarget == comProxy); Debug.Assert(wrapper.IsUniqueInstance || _rcwCache.FindProxyForComInstance(wrapper.ExternalComObject) == comProxy); // Add the input wrapper bound to the COM proxy, if there isn't one already. If another thread raced @@ -1436,7 +1429,7 @@ public Bucket() _lock.EnterWriteLock(); try { - Debug.Assert(wrapper.ProxyHandle.Target == comProxy); + Debug.Assert(wrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && proxyTarget == comProxy); ref WeakGCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); if (!exists) { @@ -1452,10 +1445,9 @@ public Bucket() } else { - object? existingProxy = cachedWrapper.ProxyHandle.Target; // The target NativeObjectWrapper was not collected, but we need to make sure // that the proxy object is still alive. - if (existingProxy is not null) + if (cachedWrapper.ProxyHandle.TryGetTarget(out object? existingProxy)) { // The existing proxy object is still alive, we will use that. return (cachedWrapper, existingProxy); @@ -1487,7 +1479,7 @@ public Bucket() return null; } if (existingHandle.TryGetTarget(out NativeObjectWrapper? cachedWrapper) - && cachedWrapper.ProxyHandle.Target is object cachedProxy) + && cachedWrapper.ProxyHandle.TryGetTarget(out object? cachedProxy)) { // The target exists and is still alive. Return it. return cachedProxy; diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs index e63c6900ac5b6e..a4fee975aa8338 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs @@ -86,7 +86,7 @@ internal static void ReleaseExternalObjectsFromCurrentThread() { wrappersToRemove.Add(nativeObjectWrapper); - object? target = nativeObjectWrapper.ProxyHandle.Target; + object? target = nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null; if (target != null) { objects.Add(target); From 6ac4779c975f798eddff76a7f3796434ebe5f21d Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 01:02:00 -0700 Subject: [PATCH 6/6] Branch directly on TryGetTarget when collecting tracked proxies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/InteropServices/TrackerObjectManager.NativeAot.cs | 3 ++- .../src/System/Runtime/InteropServices/TrackerObjectManager.cs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs index db592b2f76606c..e24968b025bdd3 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs @@ -186,7 +186,8 @@ internal static void DetachNonPromotedObjects() ReferenceTrackerNativeObjectWrapper? nativeObjectWrapper = Unsafe.As(weakNativeObjectWrapperHandle.Target); if (nativeObjectWrapper != null && nativeObjectWrapper.TrackerObject != IntPtr.Zero && - !RuntimeImports.RhIsPromoted(nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null)) + nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && + !RuntimeImports.RhIsPromoted(proxyTarget)) { // Notify the wrapper it was not promoted and is being collected. BeforeWrapperFinalized(nativeObjectWrapper.TrackerObject); diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs index a4fee975aa8338..8de9a4d60bd455 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs @@ -86,8 +86,7 @@ internal static void ReleaseExternalObjectsFromCurrentThread() { wrappersToRemove.Add(nativeObjectWrapper); - object? target = nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null; - if (target != null) + if (nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? target)) { objects.Add(target); }