Skip to content

Use System.Threading.Lock on NET9_0_OR_GREATER#1118

Merged
mtmk merged 3 commits into
release/3.0from
perf/threading-lock
May 1, 2026
Merged

Use System.Threading.Lock on NET9_0_OR_GREATER#1118
mtmk merged 3 commits into
release/3.0from
perf/threading-lock

Conversation

@mtmk

@mtmk mtmk commented Apr 29, 2026

Copy link
Copy Markdown
Member

Replace `private readonly object` lock fields with `System.Threading.Lock` under `#if NET9_0_OR_GREATER`. On .NET 9+, the C# compiler emits `Lock.EnterScope()` calls instead of `Monitor.Enter`/`Exit`, avoiding the object-header overhead. The `lock (x)` syntax is unchanged.

Nine lock fields updated across core (CommandWriter, NatsConnection, NatsSubBase, SubscriptionManager, ObjectPool, ReplyTask) and JetStream (NatsJSConsume, NatsJSOrderedConsume, NatsJSConsumer). Downlevel TFMs fall through to the existing `object` path.

@synadia-claude-reviewer

synadia-claude-reviewer Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @mtmk's task in 6m 46s —— View job


PR Review: Use System.Threading.Lock on NET9_0_OR_GREATER

  • Gather context (CLAUDE.md, PR diff, changed files)
  • Review correctness of conditional compilation approach
  • Check thread safety and reentrancy implications
  • Review each changed file for issues
  • Post final review summary

Summary

The overall approach is sound: the project's TFM list (netstandard2.0;netstandard2.1;net8.0;net10.0) means NET9_0_OR_GREATER is active for the net10.0 build, where System.Threading.Lock is available and the C# compiler (with LangVersion=latest) will correctly emit Lock.EnterScope() instead of Monitor.Enter/Exit. Field declarations and lock() usage are consistent across all 9 files.

However, there is one correctness concern worth investigating before merging.


Issue: SubscriptionManager._gate is relied upon as reentrant

System.Threading.Lock is not reentrant — on .NET 9+, EnterScope() throws LockRecursionException if the same thread tries to re-acquire the lock it already holds. Monitor.Enter (used by lock (object)) silently allows recursion.

There is a live re-entrancy path in the existing code:

SubscriptionManager.PublishToClientHandlersAsync (line 103) acquires SubscriptionManager._gate and then — still inside the lock — calls return sub.ReceiveAsync(...). Because ReceiveAsync is async ValueTask, its synchronous prefix runs immediately while the gate is held.

Inside that synchronous prefix, before the first await, NatsSubBase.ReceiveAsync can call EndSubscription:

// NatsSubBase.ReceiveAsync — synchronous portion, executed while SubscriptionManager._gate is held
case { StopOnEmptyMsg: true }:
    EndSubscription(NatsSubEndReason.EmptyMsg);   // ← synchronous
    return;
// Also triggered via SetException → EndSubscription for ThrowIfNoResponders + 503 responses
case { ThrowIfNoResponders: true } when IsHeader503(headersBuffer):
    SetException(new NatsNoRespondersException()); // ← synchronous
    return;

EndSubscription (line 484) calls UnsubscribeAsync() synchronously (fire-and-forget pattern, discarding the ValueTask). UnsubscribeAsync is not async; its body runs immediately and ends with:

return _manager.RemoveAsync(this);   // synchronous call into SubscriptionManager

SubscriptionManager.RemoveAsync (line 173) then tries to lock (_gate) — the same SubscriptionManager._gate already held on this thread → LockRecursionException on .NET 9+.

Under the old object lock this was invisible: Monitor.Enter simply incremented the recursion count.

Affected scenarios:

  • RequestManyAsync — uses StopOnEmptyMsg = true (ReplyManyOptsDefault, NatsConnection.RequestReply.cs:18). Receives a zero-byte sentinel message after the result stream ends.
  • Any subscription with ThrowIfNoResponders = true that receives a NATS 503 "No Responders" status message (e.g. RequestAsync when no subscribers exist, ReplyOptsDefault line 13).

Both are common, non-pathological code paths.

Fix suggestion →


Everything else looks correct

  • All 9 #if NET9_0_OR_GREATER blocks are properly guarded with #else / #endif.
  • No Monitor.TryEnter, Monitor.Enter(ref bool), or other low-level patterns are used on the replaced fields — only the lock() statement, which compiles to Lock.EnterScope() on .NET 9+.
  • The lockHeld parameters in CommandWriter's state machine methods guard a SemaphoreSlim (_semLock), not any of the replaced lock fields — no conflict.
  • ReplyTask._gate is initialized in the constructor (new System.Threading.Lock()), correctly mirroring the old new object().
  • All other lock (_gate) usages across the changed files are sequential (not nested on the same call stack).
  • The performance intent is correct: changing the static type of the field to System.Threading.Lock is what causes the compiler to emit Lock.EnterScope() rather than Monitor.Enter — leaving the field typed as object would not get the optimization even at runtime.

@github-actions

github-actions Bot commented Apr 29, 2026

Copy link
Copy Markdown

@mtmk mtmk closed this Apr 29, 2026
@mtmk mtmk reopened this Apr 29, 2026
@synadia-claude-reviewer

synadia-claude-reviewer Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @mtmk's task in 2m 3s —— View job


PR Review: Use System.Threading.Lock on NET9_0_OR_GREATER

  • Gather context and read CLAUDE.md
  • Review the PR diff and changed files
  • Check the reentrancy fix (commit 85c17a6) for correctness
  • Post final review summary

Summary

The PR looks correct and ready to merge. The reentrancy issue flagged in the previous review has been fixed properly in commit 85c17a6.


Reentrancy Fix — Verified Correct

The previous review identified that SubscriptionManager.PublishToClientHandlersAsync called sub.ReceiveAsync(...) while holding _gate, which could chain into EndSubscription → UnsubscribeAsync → _manager.RemoveAsync → lock(_gate), causing LockRecursionException on .NET 9+.

The fix in SubscriptionManager.cs:103–134 is correct:

  • targetSub is captured inside lock (_gate)
  • The lock is released before targetSub.ReceiveAsync(...) is called
  • This breaks the reentrancy chain entirely

The post-lock window (where another thread could dispose targetSub between capture and ReceiveAsync) is safe: we hold a strong reference via TryGetTarget, and ReceiveAsync already handles ChannelClosedException gracefully for this kind of race.


Everything Else Checked

  • CommandWriter: lock (_lock) protects only _socketConnection/_ctsReader/_readerLoopTask in short, non-nested blocks. The lockHeld parameter throughout refers to _semLock (a SemaphoreSlim) — no conflict with the replaced _lock field.
  • PartialSendFailureCounter._gate: Nested private class, used only for simple counter protection. No cross-lock nesting.
  • ObjectPool, NatsConnection, NatsSubBase, NatsJSConsume, NatsJSOrderedConsume, NatsJSConsumer: All lock() usages on the replaced fields are sequential, non-reentrant. _pendingGate in the JetStream consumers is isolated and never nested with NatsSubBase._gate or SubscriptionManager._gate.
  • ReplyTask._gate: Field declared without inline initializer, correctly initialized as new System.Threading.Lock() in the constructor.
  • All 9 #if NET9_0_OR_GREATER / #else / #endif guards are syntactically correct.
  • NET9_0_OR_GREATER is active for the net10.0 TFM in the project's target framework list, where System.Threading.Lock is available.

@mtmk

mtmk commented Apr 29, 2026

Copy link
Copy Markdown
Member Author

Benchmarks run on this branch (BenchmarkDotNet, Release build, Windows):

Raw lock microbench (LockBench):

Method Runtime Mean Ratio
ObjectLock_Empty .NET 8.0 20.31 ns 1.00
SystemLock_Empty .NET 8.0 20.32 ns 1.00
ObjectLock_Empty .NET 10.0 20.42 ns 1.00
SystemLock_Empty .NET 10.0 17.87 ns 0.88

On .NET 8 both rows use object (the #if guard is inactive), so they're identical -- expected. On .NET 10 System.Threading.Lock saves ~2.5 ns per acquire/release, about 12% on the raw operation.

Serial publish throughput (PublishRuntimeBench, 100k msgs, 128-byte payload):

Runtime Mean
.NET 8.0 35.35 ms
.NET 10.0 32.32 ms

~9% faster on .NET 10 overall. The lock change contributes a fraction of that -- at ~3M msg/s the 2.5 ns saving per lock cycle adds up to well under 1% of total publish time. The bulk of the runtime delta is JIT and pipeline improvements.

@mtmk mtmk added this to the Release 3.0 milestone Apr 30, 2026
@mtmk mtmk self-assigned this Apr 30, 2026
@mtmk
mtmk requested a review from scottf April 30, 2026 10:39

@MauriceVanVeen MauriceVanVeen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mtmk
mtmk merged commit 376abf8 into release/3.0 May 1, 2026
31 of 36 checks passed
@mtmk
mtmk deleted the perf/threading-lock branch May 1, 2026 09:47
@mtmk mtmk mentioned this pull request May 1, 2026
mtmk added a commit that referenced this pull request May 27, 2026
* Add .NET 10 Target

* Release 3.0.0-preview.1 (#1071)

- Initial release with .NET 10 target
- Also dropped .NET 6 target

* Clean up NET6 and optimize NETSTANDARD (#1072)

* Clean up NET6 and optimize NETSTANDARD

- Remove .NET 5/6/7 from `#if` directives and build
- Delete commented-out file
- Optimize NETSTANDARD encoding polyfills used in hot paths
  to eliminate intermediate array allocations
- Fix `PeriodicTimer` CancellationTokenRegistration leak
- Add encoding polyfill benchmark

* Fix `PeriodicTimer` cancellation to prevent leak

- Ensure static lambda used to avoid allocation.

* Update DI package dependencies and documentation (#1075)

Switch project dependency from `NATS.Net` to `NATS.Client.Simplified` for better modularity. Add `NATS.Extensions.Microsoft.DependencyInjection` to `NATS.Net` dependencies. Update documentation to include comparisons between `NATS.Extensions.Microsoft.DependencyInjection` and `NATS.Client.Hosting`, clarifying usage scenarios. Add new DI guide to advanced docs.

* Release 3.0.0-preview.2 (#1091)

Merged main

* Add message context to serialization interfaces (#1082)

* Add support for optional NATS headers in serialization/deserialization

* Make `Serialize` method header-aware and add unit tests for header serialization logic

* Add recursion guard to prevent infinite loops in default interface methods for `Serialize`/`Deserialize`, update tests and examples accordingly

* Fix `Content-Type` header deserialization by updating to use `StringValues`

* Fix NuidTests flap

* Improve NuidTests stability

* Remove old default methods

* Remove `IDictionary` implementation from `NatsHeaders`

* Add INatsSerializeWithHeaders

* serializer: remove unnecessary obsolete warning pragmas

The CS0618 suppressions were leftover from the DIM approach
where interface members were marked obsolete. No longer needed
with the separate interface design.

* tests: add serialization extension and ABI checks

Add unit tests for NatsSerializationExtensions covering both
the header-aware path and the fallback path for serialize and
deserialize. Make the ABI check program assert correctness
instead of just printing, including the new WithHeaders
interfaces.

* serializer: replace WithHeaders with WithContext interfaces

Replace INatsSerializeWithHeaders<T>/INatsDeserializeWithHeaders<T>
with INatsSerializeWithContext<T>/INatsDeserializeWithContext<T>,
passing a NatsMsgContext struct that carries Subject, ReplyTo, and
Headers.

This gives serializers access to the full message envelope, enabling
subject-based dispatch and reply-to inspection without requiring
further interface changes in the future.

Also fixes the null-headers asymmetry in the extension methods:
both Serialize and Deserialize now always dispatch to the context
overload when the interface is implemented, regardless of whether
Headers is null.

* Revert "Improve NuidTests stability"

This reverts commit 42b8cbd.

* Revert "Fix NuidTests flap"

This reverts commit dcc3588.

* docs: fix stale DIM references and update serialization docs

Remove incorrect DIM claims from serialization docs, rewrite the
headers section to describe the WithContext interface design. Clean
up NativeAot example that had leftover INatsHeaders parameter
overloads from the old DIM approach. Restore dropped <returns>
doc comment on INatsDeserialize<T>.Deserialize.

* headers: drop read-only state

The read-only flag was set on NatsHeaders before passing to publish to
catch accidental mutation after publish. It was a weak guarantee since
headers are copied to a byte buffer synchronously inside CommandWriter,
so post-publish mutation cannot affect wire data anyway.

Removing it lets serializers implementing INatsSerializeWithContext<T>
mutate headers (e.g. set Content-Type) during serialization, which was
otherwise blocked by ThrowIfReadOnly. Telemetry's SetOverrideReadOnly
escape hatch is no longer needed and uses the normal indexer instead.

* tests: add net10.0 TFM and improve ABI checks

Add net10.0 target to CoreUnit, Core, and Core2 test projects.
Add CheckAbiOld control project that runs against the old NuGet
version to prove type forwarding works by contrasting assembly
versions. Centralize the old NuGet version in Directory.Build.props
and make the run script assert loaded assembly versions.

* tests: fix NU1510 package pruning errors on net10.0

Condition System.Net.Http and System.Text.RegularExpressions
package references to net481 only, where they are actually needed.
On net10.0 these are built-in and NuGet's package pruning treats
the unnecessary references as errors.

* serializers: address context review feedback

- NatsMsgContext: require Subject via constructor; drop init setters so
  netstandard shim for IsExternalInit is no longer needed.
- Add INatsSerializerWithContext<T> as a combined interface mirroring
  INatsSerializer<T>, simplifying class declarations for context-aware
  serializer+deserializer pairs.
- Built-in NatsUtf8PrimitivesSerializer, NatsRawSerializer and
  NatsJsonContextSerializer now implement INatsSerializerWithContext<T>
  and propagate the context through _next when delegating. Without this,
  chaining a built-in with a context-aware leaf silently dropped context
  at the leaf.
- Document NatsHeaders thread-safety: not safe to share across concurrent
  publishes, since readonly-locking was removed.
- Document that header-mutating serializers must get non-null headers
  from the caller; the library does not allocate on their behalf.
- Add chain-propagation tests for serialize and deserialize paths.

* sln: remove x64/x86 platform noise

* Release 3.0.0-preview.3 (#1113)

* Use System.Threading.Lock on NET9_0_OR_GREATER (#1118)

* core: use System.Threading.Lock on NET9_0_OR_GREATER

* core: fix reentrant lock in PublishToClientHandlersAsync

* bench: add lock and publish runtime comparison benchmarks

* Release 3.0.0-preview.4 (#1123)

* Release 3.0.0-preview.5 (#1130)

* Release 3.0.0-preview.6 (#1141)

* di: clarify package descriptions for NuGet [no ci]
@mtmk mtmk mentioned this pull request Jun 12, 2026
1 task
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.

2 participants