Skip to content

perf(threading): reduce MCS lock handoff latency - #12417

Merged
benaadams merged 1 commit into
masterfrom
locks-mcslock
Jul 13, 2026
Merged

perf(threading): reduce MCS lock handoff latency#12417
benaadams merged 1 commit into
masterfrom
locks-mcslock

Conversation

@benaadams

Copy link
Copy Markdown
Member

Changes

Split from #12396 (measurements there).

  • The MCS queue head now keeps spinning instead of parking: it is next in line to acquire, so parking only added wake latency to every contended handoff. Depth-1 handoff throughput roughly doubles in the stress tests.
  • Waiters move through an atomic Waiting -> Parked -> ReadyToAcquire state machine; the store-fence-load pairing closes the missed-wakeup window between the spin phase and the park.
  • Reentrant acquisition throws instead of silently deadlocking.
  • ClockCache deliberately keeps this improved McsLock: a .NET 10 System.Threading.Lock swap was measured on the reproducible benchmarks and rejected. After the sender cache moved to AssociativeCache (separate PR), the remaining ClockCache users (TxPool account cache, chain-level maintenance) are low-contention background work where bounded-spin-then-park is the right CPU-isolation policy (measured wait after this change: ~nil).

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

McsLockTests extended: reentrancy throw, parked-waiter handoff observation (via the test-only current-node hook), mixed spin/park stress, and the pre-existing suite; ClockCache suite unchanged and green.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

The queue head now keeps spinning instead of parking - it is next to acquire,
so parking only added wake latency to every handoff - and waiters move through
an atomic Waiting -> Parked -> ReadyToAcquire state machine whose store-fence-
load pairing closes the missed-wakeup window between the spin phase and the
park. Depth-1 handoff throughput roughly doubles in the stress tests.
Reentrant acquisition now throws instead of deadlocking silently.

ClockCache keeps this improved McsLock: a .NET 10 System.Threading.Lock swap
was measured on the reproducible benchmarks and rejected - after the sender
cache moved off ClockCache its remaining users (TxPool account cache,
chain-level maintenance) are low-contention background work where bounded
spinning followed by parking is the right CPU-isolation policy.
Copilot AI review requested due to automatic review settings July 13, 2026 12:05
@claude

claude Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 4m 34s —— View job


Review: PR #12417 — reduce MCS lock handoff latency

  • Read changed files (McsLock, ClockCache, MockLock, tests)
  • Analyzed the state machine & memory ordering
  • Checked performance & edge cases
  • Posted findings

Solid, well-reasoned change. I walked the full Waiting → Parked → ReadyToAcquire state machine against the missed-wakeup and empty-queue-release races and could not find a correctness hole. Summary of the analysis and the few minor notes below.

Correctness — the handoff/park state machine ✅

The core store-fence-load pairing is sound:

  • Releaser (Dispose, McsLock.cs:194) publishes the handoff with Interlocked.Exchange(next.State, ReadyToAcquire) (full fence + release) and learns from the returned value whether the successor already committed to Parked — only then does it take the monitor and Pulse.
  • Waiter (WaitForSignal, McsLock.cs:143) does CAS(Waiting → Parked) under lock(node), and only Monitor.Waits if that CAS wins. If the releaser's Exchange already moved the state to ReadyToAcquire, the CAS fails and the waiter never sleeps.

I checked the three interleavings (waiter-parks-first, releaser-exchanges-first, and the interleaved case where the releaser blocks on lock(next) until the waiter is inside Monitor.Wait) — all hand off without a lost wakeup. The defensive sw.Reset() after a wake without handoff is genuinely just defensive.

The Acquired/Unlocked doubling as the head-of-queue signal (isQueueHead, McsLock.cs:104) is also correct: while a predecessor is still Waiting/Parked the successor is genuinely not next in line and parks; once the predecessor reaches ReadyToAcquire/Acquired/Unlocked the successor is (about to be) head and keeps spinning. The only transient window is the few instructions between a fresh acquirer setting Waiting (:60) and Acquired (:70), which merely costs one avoidable park at worst — not a correctness issue, and it self-corrects via the pulse path.

Memory ordering ✅

Dropping the two Interlocked.MemoryBarrier() calls from the old acquire/release paths is safe: the synchronization edge is now the release-semantics Interlocked.Exchange on the releaser paired with the acquire-semantics volatile read of node.State on the waiter (emits ldar/dmb on ARM64). Critical-section reads cannot hoist above that volatile read. Removing volatile from _tail (:40) is likewise fine — every access goes through Interlocked.Exchange/CompareExchange.

Consistency of the Acquire → EnterScope rename ✅

  • McsLock keeps Acquire() as an alias, so the ~50 existing call sites (LruCache, SortedPool, TxPool pools, AuRaSealValidator, XdcPool) keep compiling.
  • MockLock (only consumed by ClockCache under #if ZK_EVM) was renamed to EnterScope() and ClockCache updated to match, so both branches of the conditional field type expose the method it calls. Verified MockLock has no other callers.
  • Nethermind.Core.Test has InternalsVisibleTo, so the new tests' access to ThreadNode, LockState, and CurrentThreadNodeForTesting compiles.

Tests ✅

Good coverage of the new paths: reentrancy throw, the parked-waiter pulse path (via the test-only node hook), fairness, reacquire-after-release, and a mixed spin/park mutual-exclusion stress test.

Findings

Severity Finding
Low Reentrant acquire now throws InvalidOperationException instead of self-deadlocking — a caller-visible contract change (see inline note on :58). Strictly an improvement, but worth a one-line mention since the PR is tagged only "Optimization". Confirm no call site nests the same lock on one thread.
Nit CurrentThreadNodeForTesting adds a small internal-only test seam; acceptable given it's internal and there was no cleaner way to observe Parked.

No Critical/High/Medium issues. Nice work — the doubled depth-1 handoff throughput comes from a genuinely tighter, still-correct protocol.

Comment thread src/Nethermind/Nethermind.Core/Threading/McsLock.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Optimizes McsLock handoff behavior to reduce contended lock latency by keeping the queue head in a bounded yield/spin phase (instead of parking), adding an explicit waiter state machine to close missed-wakeup windows, and failing fast on reentrant acquisition to avoid self-deadlocks.

Changes:

  • Update McsLock to use a Waiting -> Parked -> ReadyToAcquire state machine with bounded head-of-queue yielding and conditional pulsing of parked successors.
  • Add reentrancy detection (throw on reentrant acquire) and expose a test hook for observing the current thread’s queue node.
  • Refresh ClockCache lock usage and extend/rename the test suite to validate the new lock semantics and stress behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Nethermind/Nethermind.Core/Threading/MockLock.cs Renames the scope-entry API to match the updated lock usage pattern.
src/Nethermind/Nethermind.Core/Threading/McsLock.cs Implements the new head-spin + park state machine and reentrancy failure behavior.
src/Nethermind/Nethermind.Core/Caching/ClockCache.cs Switches to the updated lock scope-entry call site (EnterScope).
src/Nethermind/Nethermind.Core.Test/MCSLockTests.cs Removes the prior test file (case/rename and replacement).
src/Nethermind/Nethermind.Core.Test/McsLockTests.cs Adds expanded tests for reentrancy, parked handoff observation, and mixed spin/park stress.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Nethermind/Nethermind.Core/Threading/MockLock.cs
Comment thread src/Nethermind/Nethermind.Core.Test/McsLockTests.cs
@github-actions

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-locks-mcslock-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 994.67 997.53 -0.29%
MEDIAN (ms) 935.9 929.3 +0.71%
P90 (ms) 1187.2 1212.0 -2.05%
P95 (ms) 1502.9 1488.5 +0.97%
P99 (ms) 2586.0 3469.9 -25.47%
MIN (ms) 672.3 686.0 -2.00%
MAX (ms) 2586.0 3469.9 -25.47%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1198.84 1203.05 -0.35%
MEDIAN (ms) 1089.39 1083.19 +0.57%
P90 (ms) 1450.56 1408.85 +2.96%
P95 (ms) 1800.76 1812.17 -0.63%
P99 (ms) 3051.54 3860.26 -20.95%
MIN (ms) 820.97 817.38 +0.44%
MAX (ms) 5574.04 6094.66 -8.54%

realblocks

Scenario: nethermind-flat-realblocks-locks-mcslock-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 25.99 26.07 -0.31%
MEDIAN (ms) 21.3 21.8 -2.29%
P90 (ms) 44.7 44.1 +1.36%
P95 (ms) 55.2 55.5 -0.54%
P99 (ms) 107.1 101.4 +5.62%
MIN (ms) 0.3 0.4 -25.00%
MAX (ms) 203.6 187.6 +8.53%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 29.82 30.08 -0.86%
MEDIAN (ms) 25.32 25.89 -2.20%
P90 (ms) 48.18 48.84 -1.35%
P95 (ms) 59.35 60.84 -2.45%
P99 (ms) 100.84 103.88 -2.93%
MIN (ms) 1.04 0.96 +8.33%
MAX (ms) 206.64 191.36 +7.98%

fusaka

Scenario: nethermind-flat-fusaka-locks-mcslock-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 33.39 33.52 -0.39%
MEDIAN (ms) 29.1 29.3 -0.68%
P90 (ms) 51.2 51.1 +0.20%
P95 (ms) 60.8 62.0 -1.94%
P99 (ms) 123.0 122.5 +0.41%
MIN (ms) 4.8 4.8 +0.00%
MAX (ms) 519.1 526.4 -1.39%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 39.02 39.19 -0.43%
MEDIAN (ms) 34.51 34.89 -1.09%
P90 (ms) 60.29 59.81 +0.80%
P95 (ms) 68.87 70.11 -1.77%
P99 (ms) 127.28 126.47 +0.64%
MIN (ms) 5.89 6.24 -5.61%
MAX (ms) 521.91 529.82 -1.49%

@benaadams
benaadams merged commit e87738b into master Jul 13, 2026
529 checks passed
@benaadams
benaadams deleted the locks-mcslock branch July 13, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants