Consider migrating ConditionalWeakTable<TKey, TValue>._lock to System.Threading.Lock
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
> *Filed with the help of Claude Code (Anthropic CLI). All claims below were verified against the source at HEAD on the date of filing; benchmark numbers were measured locally.*
## Background
`ConditionalWeakTable` currently uses `lock (object _lock)` to protect all mutations of its internal container. Since .NET 9, [`System.Threading.Lock`](https://github.com/dotnet/runtime/issues/34812) is the recommended type for new lock fields — purpose-built, JIT-specialized, and free of the sync-block dual-role overhead of locking on an arbitrary `object`.
## Benchmark
Stephen Toub's .NET 9 perf-blog benchmark, re-run on .NET 11 (post-[#118371](https://github.com/dotnet/runtime/pull/118371) and [#124878](https://github.com/dotnet/runtime/pull/124878)):
```csharp
[MemoryDiagnoser]
[HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Tests
{
private readonly object _monitor = new();
private readonly Lock _lock = new();
private int _value;
[Benchmark(Baseline = true)]
public void WithMonitor() { lock (_monitor) { _value++; } }
[Benchmark]
public void WithLock() { lock (_lock) { _value++; } }
}
```
Windows 11, i7-13700KF, .NET 11.0.0-preview.5:
| Method | Mean | Ratio | Allocated |
|------------ |-------------:|---------:|----------:|
| WithMonitor | 12.01 ns | 1.00 | — |
| WithLock | **10.75 ns** | **0.90** | — |
This is an upper bound — CWT's lock body (`Add`/`TryGetValue`/etc.) does considerably more work than `_value++`, so the absolute per-acquire saving (~1.26 ns) is a smaller percentage of total operation cost. Rough estimate: 0.5–2% improvement on uncontested CWT mutations.
## Costs
- **Per-instance memory.** `Lock` has ~24 B of instance fields vs `object`'s 0 B → ~16 B more per CWT on x64. CWT can have many live instances (attached-property patterns, source-gen state, etc.) so this is real but probably small in absolute terms.
- **Two `Monitor.IsEntered(_lock)` Debug.Assert calls** (Enumerator ctor and `CreateEntry`) become `_lock.IsHeldByCurrentThread`. Trivial.
- **Re-entrancy not required**, verified by inspection. Both `lock (object)` and `Lock` are re-entrant anyway.
## Native correctness verified
The native VM has a layout mirror at `src/coreclr/vm/conditionalweaktable.h:50`:
```cpp
class ConditionalWeakTableObject final : public Object
{
friend class CoreLibBinder;
OBJECTREF _lock;
VolatilePtr<...> _container;
};
```
CoreLibBinder (`binder.cpp:672-680`) validates field offset and size. `Lock` is a managed reference (8 B on x64), same size as `object` — check passes, `_container`'s offset unchanged. Confirmed no native or managed code outside `ConditionalWeakTable.cs` itself reads, writes, or locks on `_lock`:
- `src/coreclr/vm/conditionalweaktable.cpp` — DAC-only `TryGetValue`, never touches `_lock`
- `src/coreclr/vm/gcenv.ee.cpp` — profiler hooks operate on `DependentHandle` primary/secondary pairs
- `src/coreclr/inc/profilepriv.h` — profiler API uses object IDs
- `src/coreclr/jit/` — zero hits
- NativeAOT runtime — no `ConditionalWeakTableObject` references
- Mono — `ConditionalWeakTable` mentioned only in eventpipe naming
- cDAC (`ConditionalWeakTable_1.cs`) — reads `_container`, `_buckets`, `_entries`, `HashCode`, `Next`, `depHnd` by name; `_lock` is not in the contract
## Questions
1. Would the team be open to a PR if benchmark numbers on a representative CWT-heavy workload (rather than the synthetic `_value++` microbench) showed a measurable win?
2. Anything missed beyond per-instance memory and the `IsEntered` rewrite?
Contributor guide
Assessment
This issue has not been assessed yet.