Skip to content

perf(CommunityToolkit.Mvvm): use System.Threading.Lock (NET9+) and FrozenDictionary (NET8+)#1192

Open
Rolling2405 wants to merge 2 commits intoCommunityToolkit:mainfrom
Rolling2405:feat/net9-lock-perf
Open

perf(CommunityToolkit.Mvvm): use System.Threading.Lock (NET9+) and FrozenDictionary (NET8+)#1192
Rolling2405 wants to merge 2 commits intoCommunityToolkit:mainfrom
Rolling2405:feat/net9-lock-perf

Conversation

@Rolling2405
Copy link
Copy Markdown

Summary

This PR adds two targeted performance improvements to CommunityToolkit.Mvvm for projects targeting .NET 9+ or .NET 8+, guarded by #if NET9_0_OR_GREATER / #if NET8_0_OR_GREATER so all existing TFMs are completely unaffected.


1. \System.Threading.Lock\ for messenger lock fields (NET9+)

Files changed: \WeakReferenceMessenger.cs, \StrongReferenceMessenger.cs, \ConditionalWeakTable2{TKey,TValue}.ZeroAlloc.cs\

Both messengers previously used \lock (this.recipientsMap)\ — locking directly on the data-holding dictionary field. This PR introduces a dedicated
ecipientsMapLock\ field (typed as \System.Threading.Lock\ on NET9+, \object\ on older targets) and redirects all lock sites to use it.

On .NET 9+, the C# 13 \lock\ statement automatically calls \Lock.EnterScope()\ instead of \Monitor.Enter/\Monitor.Exit, which:

  • Avoids the atomic compare-exchange inside \Monitor.Enter's SyncBlock lookup
  • Enables a truly non-blocking \TryEnter\ path (\WeakReferenceMessenger.CleanupWithNonBlockingLock)
  • Separates data ownership from synchronization semantics

\ConditionalWeakTable2\ uses a cross-method lock held across \GetEnumerator()/\Enumerator.Dispose(). Because \Lock.Scope\ is a
ef struct\ it cannot span method boundaries, so this site uses \lockObject.Enter()/\lockObject.Exit()\ directly with #if\ guards (fixing the CS9216 boxing-of-Lock diagnostic).

\CommunityToolkit.Mvvm.csproj\ is updated to \LangVersion=13.0\ to enable the optimised lock statement. This mirrors the pattern already used in the Roslyn test projects (\Roslyn5000.UnitTests\ uses 14.0) and only affects this library project.

2. \FrozenDictionary\ for \ObservableValidator.DisplayNamesMap\ (NET8+)

File changed: \ObservableValidator.cs\

\DisplayNamesMap\ is a \ConditionalWeakTable<Type, Dictionary<string,string>>\ — the inner dictionary is built once per validated type via reflection, then only ever read. On NET8+, this PR changes the inner dictionary type to \FrozenDictionary<string,string>:

  • \FrozenDictionary\ generates a minimal perfect hash at creation time
  • All subsequent \TryGetValue\ calls (on every validated property) use constant-time lookups with no collision chains
  • The \ConditionalWeakTable\ wrapper handles lifetime correctly (\FrozenDictionary\ is a reference type ✅)

Testing

All 408 tests in \CommunityToolkit.Mvvm.Roslyn5000.UnitTests\ pass on
et10.0:

\
Passed! - Failed: 0, Passed: 408, Skipped: 0, Total: 408
\\

The \SourceGenerators.Roslyn5000.UnitTests\ project was intentionally not targeted — it has pre-existing failures on
et10.0\ unrelated to this change (diagnostic text differences due to C# 14 defaults), as noted in PR #1191.


Related

Companion to PR #1191 (net10.0 TFM addition). These improvements are meaningful regardless of that PR but are most impactful when the library is consumed on .NET 9/10.

Rolling2405 and others added 2 commits April 30, 2026 22:39
Add net10.0 and net10.0-windows10.0.17763.0 to CommunityToolkit.Mvvm's
TargetFrameworks, following the existing LTS-only shipping pattern
(net8.0 LTS → net10.0 LTS, skipping net9.0 STS).

All existing conditional blocks use IsTargetFrameworkCompatible against
'net8.0-windows10.0.17763.0', which returns true for the new
net10.0-windows TFM, so Windows-specific code paths apply automatically.
NET8_0_OR_GREATER symbols are true for net10.0 as well, so no source
changes are required.

Also add net10.0 to CommunityToolkit.Mvvm.Roslyn5000.UnitTests, which
was the only MVVM runtime test project not yet targeting net10.0.
All 408 tests pass on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ionary on NET9+/NET8+

- WeakReferenceMessenger: replace lock(this.recipientsMap) with a dedicated
  recipientsMapLock field typed as Lock on NET9+, object on older targets.
  C# 13 automatically uses Lock.EnterScope() for the lock statement, avoiding
  Monitor.Enter/Exit overhead and enabling a non-blocking TryEnter cleanup path.

- StrongReferenceMessenger: same dedicated lock field pattern (6 lock sites).

- ConditionalWeakTable2: upgrade lockObject from object to Lock on NET9+.
  The cross-method lock held across GetEnumerator/Enumerator.Dispose uses
  lockObject.Enter()/Exit() directly (Lock.Scope is a ref struct and cannot
  span method boundaries). Monitor.Enter/Exit replaced with #if guards to
  avoid the CS9216 boxing-of-Lock warning.

- ObservableValidator: use FrozenDictionary<string,string> for DisplayNamesMap
  on NET8+ (write-once via reflection, read-many thereafter). ToFrozenDictionary
  generates a minimal perfect hash for faster property-name lookups.

- CommunityToolkit.Mvvm.csproj: set LangVersion to 13.0 to enable the optimised
  lock statement for System.Threading.Lock. Other Toolkit projects remain on 12.0;
  this override mirrors the pattern already used in the Roslyn test projects.
@Rolling2405
Copy link
Copy Markdown
Author

@dotnet-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant