Repository navigation
Description
Background and motivation
ComWrappers keeps the state it associates with an RCW in a static ConditionalWeakTable<object, NativeObjectWrapper> keyed on the RCW itself. That state is the native pointer, the owning ComWrappers instance, and the GC handles that track the RCW's lifetime.
Keying a table on an object means the object needs a hash code, and assigning one inflates its header into a sync block. That happens once per RCW, and then every lookup is a table probe on top. The table is consulted when an RCW is created, every time an RCW is passed back out to native code (TryGetComInstance, TryGetComInstanceForIID), and when a COM weak reference is created.
For anything doing heavy interop, that adds up. In CsWinRT every WinRT object handed to managed code becomes an RCW, and profiling puts the table squarely in the top costs of the interop layer. The numbers below are from a ComWrappers whose RCWs are ordinary objects versus one whose RCWs derive from the proposed type, measured in the same process so the comparison needs no build swap:
| table (today) | base type | ||
|---|---|---|---|
| Create an RCW | 208.0 ns | 154.2 ns | -26% |
| Create an RCW, separate tracker wrapper | 250.9 ns | 146.9 ns | -41% |
Pass an RCW to native code (TryGetComInstance) |
40.96 ns | 29.81 ns | -27% |
| Get an RCW back from native code (control, never used the table) | 64.24 ns | 63.25 ns | -1.5% |
| Allocated per RCW | 114 B | 88 B | -23% |
The last row is worth calling out: the extra field makes each RCW 8 bytes bigger, and it is still a net win, because the table entry it replaces costs more than that.
There is no workaround available today. The association is entirely internal to ComWrappers, and a caller cannot tell it where to keep its state, so the only way to avoid the cost is to not use ComWrappers.
Related, though none propose this: #120551, #113591, #113622, #113581.
API Proposal
namespace System.Runtime.InteropServices;
[UnsupportedOSPlatform("android")]
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
public abstract class ComWrappersObject
{
protected ComWrappersObject();
}No members beyond the constructor. The platform attributes mirror ComWrappers exactly.
An object returned from ComWrappers.CreateObject that derives from this type gets its NativeObjectWrapper stored in an internal field on the object instead of in the table. Nothing else about it changes, and mixing derived and non-derived RCWs in the same process is fine.
The prototype also moves the runtime's own RCW onto it, which is a public change of its own:
namespace System.Runtime.InteropServices.Marshalling;
public sealed partial class ComObject : ComWrappersObject // was: no base type
{
}Prototype: api-proposal/comwrappers-object
7d7813a0f83: the API and its implementationd1dd9982681:ComObjectadopting itc57f5cff44f: an unrelated RCW cache change, present because the concurrency numbers depend on it (see Risks), being sent as its own PR
Validated with the seven COM interop suites under src/tests/Interop/COM against a Checked runtime, plus System.Runtime.InteropServices.Tests (3267) and ComInterfaceGenerator.Tests (194), which is what exercises ComObject. All pass.
API Usage
Opting in is a base type and nothing else:
sealed class MyObject : ComWrappersObject
{
// whatever the wrapper needs, unchanged
}
sealed class MyComWrappers : ComWrappers
{
protected override object CreateObject(nint externalComObject, CreateObjectFlags flags)
{
return new MyObject();
}
protected override unsafe ComInterfaceEntry* ComputeVtables(object obj, CreateComInterfaceFlags flags, out int count) => throw new NotImplementedException();
protected override void ReleaseObjects(IEnumerable objects) => throw new NotImplementedException();
}Everything that used to consult the table now reads the field, with no change at the call site:
MyObject rcw = (MyObject)comWrappers.GetOrCreateObjectForComInstance(ptr, CreateObjectFlags.None);
// no table lookup
ComWrappers.TryGetComInstance(rcw, out nint unknown);Alternative Designs
- An interface with a ref-returning property, so types that already have a base class can opt in. Rejected: nothing stops an implementer returning a ref to something that isn't a stable field, which
ComWrapperswould then use forInterlockedoperations; it puts interface dispatch on the hot path; and the slot becomes visible public API on every implementing type. - A pair of
protected virtualmembers onComWrappersto get and set an opaque state object, letting the derivedComWrappersdecide where to keep it. More flexible, and it also solves the "already has a base class" case, but it adds two virtual calls to the hottest paths and makes lifetime correctness the caller's problem. Worth discussing if the base class restriction is considered too limiting. - Making the table faster. There isn't a faster general object-to-state map than a field on the object; the hash code is inherent to keying on an object.
- The CCW direction too.
GetOrCreateComInterfaceForObjecthas the same shape of cost against a secondConditionalWeakTable. It is deliberately out of scope here: that table is perComWrappersinstance rather than global, so one field cannot serve it, and the type that would need the base class is the user's own managed type rather than a wrapper.
Risks
- Types that already have a base class cannot opt in. This is the main limitation of the design, and the reason the second alternative above is worth a look.
MemberwiseClonecopies the field. A shallow copy of an RCW is itself reported as an RCW referring to the same native object, which is not true of a table-tracked RCW. Verified with a probe. There is no way to intercept it, so it is documented on the type.- SOS and cDAC stop finding these RCWs. The
IComWrappersdata contract resolves RCWs only throughs_nativeObjectWrapperTable(src/native/managed/cdac/.../Contracts/ComWrappers_1.cs:148, documented indocs/design/datacontracts/ComWrappers.md). Shipping this requires a contract revision, a data descriptor addition and dump test coverage. Not optional. - Concurrency. Removing the table also removes its global lock, which was throttling arrival at the RCW cache; on its own the base type regressed concurrent RCW creation by 25-40% at 8 and 32 threads. An independent change that lets the RCW cache share the wrapper's existing GC handle removes that, and with both applied the base type is at or better than parity at every thread count. That change is unrelated to this API and is being sent separately, but the benchmark numbers above assume it.
ComObjectgaining a base type is binary compatible, sinceComWrappersObjectadds no visible members andComObjectis sealed, but it is still a public API change and needs approving alongside this.
Usage in dotnet/runtime
Updated in prototype
| File | Description |
|---|---|
src/libraries/System.Runtime.InteropServices/src/System/Runtime/InteropServices/Marshalling/ComObject.cs |
The RCW that StrategyBasedComWrappers returns for every source-generated COM interface, so all [GeneratedComInterface] interop moves off the table with no user change |
Inapplicable
StrategyBasedComWrappers is the only production ComWrappers implementation in the repo; the remaining subclasses are test helpers in System.Runtime.InteropServices.Tests and ComInterfaceGenerator.Tests that create throwaway objects, where there is nothing to gain. The primary consumer of this API is external: CsWinRT, whose WindowsRuntimeObject is the type that would derive from it.
Note
Parts of this API proposal were generated with GitHub Copilot. All benchmark numbers in it were measured locally.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Aug 18, 2026
Tagging subscribers to this area: @dotnet/interop-contrib
See info in area-owners.md if you want to be subscribed.
bartonjs commentedon Sep 8, 2026
Video