dotnet / dotnet/runtime

[API Proposal]: `ComWrappersObject`

Open
#132,490 2 comments 0 reactions 0 assignees View on GitHub
api-approved area-System.Runtime.InteropServices
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Background and motivation

`ComWrappers` keeps the state it associates with an RCW in a static `ConditionalWeakTable` 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

```csharp
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:

```csharp
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 implementation
- `d1dd9982681`: `ComObject` adopting it
- `c57f5cff44f`: 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:

```csharp
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:

```csharp
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 `ComWrappers` would then use for `Interlocked` operations; it puts interface dispatch on the hot path; and the slot becomes visible public API on every implementing type.
- **A pair of `protected virtual` members on `ComWrappers`** to get and set an opaque state object, letting the derived `ComWrappers` decide 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.** `GetOrCreateComInterfaceForObject` has the same shape of cost against a second `ConditionalWeakTable`. It is deliberately out of scope here: that table is per `ComWrappers` instance 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.
- **`MemberwiseClone` copies 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 `IComWrappers` data contract resolves RCWs only through `s_nativeObjectWrapperTable` (`src/native/managed/cdac/.../Contracts/ComWrappers_1.cs:148`, documented in `docs/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.
- **`ComObject` gaining a base type** is binary compatible, since `ComWrappersObject` adds no visible members and `ComObject` is 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.

Contributor guide

Open the contributing guide

Research direction

Start with the prototype commits and src/libraries/System.Runtime.InteropServices/src/System/Runtime/InteropServices/Marshalling/ComObject.cs, then read the SOS contract at src/native/managed/cdac/.../Contracts/ComWrappers_1.cs and docs/design/datacontracts/ComWrappers.md. Run the seven COM interop suites, System.Runtime.InteropServices.Tests, and ComInterfaceGenerator.Tests. Done requires the API and ComObject changes, the required contract and dump-test updates, and all listed tests passing.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
backend-api-design, operating-systems
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.