Skip to content

Perf: eliminate hot-path allocations, modernize C# usage#138

Merged
ChrisPulman merged 1 commit intomainfrom
feature/perf
Mar 29, 2026
Merged

Perf: eliminate hot-path allocations, modernize C# usage#138
ChrisPulman merged 1 commit intomainfrom
feature/perf

Conversation

@glennawatson
Copy link
Copy Markdown
Contributor

What's changed for end users

This release targets the core notification pipeline — every value flowing through any async operator hits ObserverAsync.TryEnterOnSomethingCall and Concurrent.Forward*. These changes reduce per-notification allocations significantly:

  • ~200 bytes saved per notification when the caller's cancellation token is None or matches the dispose token (the common case for OnCompleted and most OnNext calls). Previously a CancellationTokenSource.CreateLinkedTokenSource was allocated on every single call.
  • Zero-allocation single-observer fast path for concurrent subjects — the most common case (1 subscriber) now avoids Task[], Task.WhenAll, LINQ iterator, and closure allocations entirely.
  • ImmutableArray replaces ImmutableList for observer tracking in all 4 subject base classes — flat array instead of balanced binary tree, better cache locality at typical observer counts (1–5).
  • Async state machine removed from ObservableAsync.SubscribeAsync — was wrapping a single await for no reason.

No public API changes. No behavior changes. Drop-in upgrade.


Summary

ObserverAsync — the hottest path in the library:

  • Introduce LinkedTokenScope record struct to avoid CancellationTokenSource.CreateLinkedTokenSource allocation (~200 bytes) on every OnNext/OnError/OnCompleted when the caller token is None or already the dispose token
  • Replace locking on AsyncLocal<int> with a dedicated gate field using System.Threading.Lock on NET9+

Concurrent subject forwarding:

  • Replace LINQ .Select().AsTask() with manual for-loop over pre-sized Task[] in all three Forward*Concurrently methods, eliminating iterator, closure, and Task wrapper allocations per observer per emission
  • Add single-observer fast path that returns the ValueTask directly without any Task[] or Task.WhenAll overhead

ObservableAsync.SubscribeAsync:

  • Remove unnecessary async state machine — was only awaiting a single call and returning the result

CompositeDisposableAsync.Clear:

  • Replace LINQ .Take(clearCount) with index-bounded for-loop to eliminate enumerator allocation

Subject base classes (BaseSubjectAsync, BaseReplayLatestSubjectAsync, BaseStatelessSubjectAsync, BaseStatelessReplayLastSubjectAsync):

  • Convert ImmutableList<IObserverAsync<T>>ImmutableArray<IObserverAsync<T>> for lower overhead and better cache locality at typical observer counts (1–5)

Operator subscription safety:

  • Extract SubscriptionHelper.SubscribeAndDisposeOnFailureAsync to consolidate the try/catch/dispose/throw pattern from ~20 call sites across CombineLatest arities, TakeUntil variants, Merge, Switch, Concat into one directly-tested internal helper

Modernization:

  • Seal AsyncGate, simplify dispose pattern
  • File-scoped namespace for WrappedObserverAsync
  • Consistent ArgumentExceptionHelper.ThrowIfNull usage in DisposableAsync and ReactiveExtensions
  • NET9 Lock conditional for BufferUntilIdle gate
  • Fix CS1734 XML doc warning in GroupBy

Test plan

  • dotnet build ReactiveUI.Extensions.slnx -c Release — 0 errors, only pre-existing SA1201 warnings from extension<T> syntax
  • dotnet test --solution ReactiveUI.Extensions.slnx -c Release — 3780 tests pass across net8.0/net9.0/net10.0 (0 failures)
  • Branch coverage 95.34% → 97.68% (57 new tests covering multi-observer concurrent subjects, terminal operator failure paths, subscription helper, operator error propagation, disposal edge cases, factory validation)

🤖 Generated with Claude Code

ObserverAsync — the hottest path in the library:
- Introduce LinkedTokenScope record struct to avoid CancellationTokenSource.CreateLinkedTokenSource allocation (~200 bytes) on every OnNext/OnError/OnCompleted when the caller token is None or already the dispose token
- Replace locking on AsyncLocal<int> with a dedicated gate field using System.Threading.Lock on NET9+

Concurrent subject forwarding:
- Replace LINQ .Select().AsTask() with manual for-loop over pre-sized Task[] in all three Forward*Concurrently methods, eliminating iterator, closure, and Task wrapper allocations per observer per emission
- Add single-observer fast path that returns the ValueTask directly without any Task[] or Task.WhenAll overhead

ObservableAsync.SubscribeAsync:
- Remove unnecessary async state machine — was only awaiting a single call and returning the result

CompositeDisposableAsync.Clear:
- Replace LINQ .Take(clearCount) with index-bounded for-loop to eliminate enumerator allocation

Subject base classes (BaseSubjectAsync, BaseReplayLatestSubjectAsync, BaseStatelessSubjectAsync, BaseStatelessReplayLastSubjectAsync):
- Convert ImmutableList<IObserverAsync<T>> to ImmutableArray<IObserverAsync<T>> for lower overhead and better cache locality at typical observer counts (1-5)

Operator subscription safety:
- Extract SubscriptionHelper.SubscribeAndDisposeOnFailureAsync to consolidate the try/catch/dispose/throw pattern from ~20 call sites across CombineLatest arities, TakeUntil variants, Merge, Switch, Concat into one directly-tested internal helper

Modernization:
- Seal AsyncGate, simplify dispose pattern
- File-scoped namespace for WrappedObserverAsync
- Consistent ArgumentExceptionHelper.ThrowIfNull usage in DisposableAsync and ReactiveExtensions
- NET9 Lock conditional for BufferUntilIdle gate
- Fix CS1734 XML doc warning in GroupBy

Test coverage:
- Add 57 tests covering multi-observer concurrent subjects, terminal operator failure paths, subscription helper, operator error propagation, disposal edge cases, and factory validation. Branch coverage 95.34% → 97.68%.
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 29, 2026

Codecov Report

❌ Patch coverage is 99.27536% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 98.92%. Comparing base (8062f23) to head (597fe1d).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...I.Extensions/Async/Internals/SubscriptionHelper.cs 85.71% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #138      +/-   ##
==========================================
+ Coverage   98.21%   98.92%   +0.71%     
==========================================
  Files         119      120       +1     
  Lines        5161     5137      -24     
  Branches      688      675      -13     
==========================================
+ Hits         5069     5082      +13     
  Misses         15       15              
+ Partials       77       40      -37     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ChrisPulman ChrisPulman merged commit 1d2d0ce into main Mar 29, 2026
6 checks passed
@ChrisPulman ChrisPulman deleted the feature/perf branch March 29, 2026 23:09
@github-actions
Copy link
Copy Markdown

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Apr 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants