Perf: eliminate hot-path allocations, modernize C# usage#138
Merged
ChrisPulman merged 1 commit intomainfrom Mar 29, 2026
Merged
Perf: eliminate hot-path allocations, modernize C# usage#138ChrisPulman merged 1 commit intomainfrom
ChrisPulman merged 1 commit intomainfrom
Conversation
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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
ChrisPulman
approved these changes
Mar 29, 2026
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's changed for end users
This release targets the core notification pipeline — every value flowing through any async operator hits
ObserverAsync.TryEnterOnSomethingCallandConcurrent.Forward*. These changes reduce per-notification allocations significantly:Noneor matches the dispose token (the common case forOnCompletedand mostOnNextcalls). Previously aCancellationTokenSource.CreateLinkedTokenSourcewas allocated on every single call.Task[],Task.WhenAll, LINQ iterator, and closure allocations entirely.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:
LinkedTokenScoperecord struct to avoidCancellationTokenSource.CreateLinkedTokenSourceallocation (~200 bytes) on everyOnNext/OnError/OnCompletedwhen the caller token isNoneor already the dispose tokenAsyncLocal<int>with a dedicated gate field usingSystem.Threading.Lockon NET9+Concurrent subject forwarding:
.Select().AsTask()with manual for-loop over pre-sizedTask[]in all threeForward*Concurrentlymethods, eliminating iterator, closure, and Task wrapper allocations per observer per emissionValueTaskdirectly without anyTask[]orTask.WhenAlloverheadObservableAsync.SubscribeAsync:
CompositeDisposableAsync.Clear:
.Take(clearCount)with index-bounded for-loop to eliminate enumerator allocationSubject base classes (BaseSubjectAsync, BaseReplayLatestSubjectAsync, BaseStatelessSubjectAsync, BaseStatelessReplayLastSubjectAsync):
ImmutableList<IObserverAsync<T>>→ImmutableArray<IObserverAsync<T>>for lower overhead and better cache locality at typical observer counts (1–5)Operator subscription safety:
SubscriptionHelper.SubscribeAndDisposeOnFailureAsyncto consolidate the try/catch/dispose/throw pattern from ~20 call sites across CombineLatest arities, TakeUntil variants, Merge, Switch, Concat into one directly-tested internal helperModernization:
AsyncGate, simplify dispose patternWrappedObserverAsyncArgumentExceptionHelper.ThrowIfNullusage inDisposableAsyncandReactiveExtensionsLockconditional forBufferUntilIdlegateGroupByTest plan
dotnet build ReactiveUI.Extensions.slnx -c Release— 0 errors, only pre-existing SA1201 warnings fromextension<T>syntaxdotnet test --solution ReactiveUI.Extensions.slnx -c Release— 3780 tests pass across net8.0/net9.0/net10.0 (0 failures)🤖 Generated with Claude Code