Skip to content

Mutex: barge instead of handing off the lock#573

Merged
lalinsky merged 2 commits into
mainfrom
barging-mutex
Jul 15, 2026
Merged

Mutex: barge instead of handing off the lock#573
lalinsky merged 2 commits into
mainfrom
barging-mutex

Conversation

@lalinsky

Copy link
Copy Markdown
Owner

The mutex handed the lock to a specific parked waiter on unlock, so every contended transfer waited for the scheduler to run that exact task — a convoy. This rewrites it as a barging lock: woken waiters compete with running tasks. Tasks still park in the mutex's own wait queue; foreign threads now wait directly on the state word with a futex instead of per-waiter Notify wakeups. Platforms without a raw futex (NetBSD) keep using the queue path.

Contended counter benchmark (4 workers × 100k lock/inc/unlock, old/new binaries interleaved per round, n=12):

mode old med new med
tasks, single-threaded 8.5 ms 6.8 ms
tasks, multi-threaded 52.0 ms 41.3 ms
OS threads 3562 ms 13.3 ms

The multi-threaded case has high variance in both versions; that turned out to be work-stealing churn rather than the mutex (separate issue). The OS-thread number is the per-waiter Notify handoff being replaced by a plain futex wait on the state word.

Trade-offs: FIFO ordering is gone (same as glibc/Go/parking_lot); a hot lock holder can starve waiters in principle. The wait queue still supports handing off directly, so eventual-fairness (periodic forced handoff) is a small follow-up if it's ever needed.

Adds foreign-thread and mixed tasks+threads tests; full suite passes (549/549).

The old mutex handed the lock to a specific parked waiter, so every
contended transfer was gated on the scheduler running that exact task,
and everyone else queued up behind the gap. Woken waiters now compete
for the lock with running tasks. Tasks still park in the mutex's own
wait queue; foreign threads wait directly on the state word with a
futex, so waking them needs no per-waiter bookkeeping (platforms
without a futex fall back to the queue path).

Contended counter, 4 workers x 100k lock/unlock, interleaved runs,
medians: single-threaded runtime 8.5 -> 6.8 ms, multi-threaded
52 -> 41 ms (high variance either way, see the work stealing issue),
OS threads 3562 -> 13 ms. The old foreign-thread path went through a
per-waiter Notify handoff, which is where the last number comes from.

FIFO ordering is gone; a task or thread that keeps re-acquiring can now
starve waiters. If that ever shows up in practice, the fix is periodic
forced handoff, which the wait queue still supports.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ddd47e66-62aa-4dc8-902e-b717a893b7ad

📥 Commits

Reviewing files that changed from the base of the PR and between 60f9e80 and a28e5f9.

📒 Files selected for processing (1)
  • src/sync/Mutex.zig
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/sync/Mutex.zig

📝 Walkthrough

Walkthrough

Futex is exported with public wait, timed-wait, and wake methods for supported operating systems. Mutex now uses atomic state, task wait queues, cancellation-aware coroutine paths, and futex-based foreign-thread locking, with tests covering thread-only and mixed usage.

Changes

Mutex and futex integration

Layer / File(s) Summary
Export platform futex operations
src/os/thread.zig
The selected Futex backend and its platform-specific wait, timedWait, and wake methods are made public without changing syscall behavior.
Implement atomic Mutex locking
src/sync/Mutex.zig
Mutex locking is rewritten around atomic state and a task wait queue, separating coroutine cancellation handling from foreign-thread futex waits and unlock wakeups.
Validate mixed locking modes
src/sync/Mutex.zig
Tests cover foreign-thread locking, combined coroutine/thread workloads, and cancellation during repeated parking and unparking.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • lalinsky/zio#40: Modifies the Mutex implementation and its queue and wake behavior.
  • lalinsky/zio#289: Changes the same per-OS futex wait and timed-wait APIs.
  • lalinsky/zio#551: Builds higher-level wait APIs on the exported futex primitives.

Poem

Atomic state guards the door,
Tasks and threads contend once more.
Futex signals cross the night,
Waiters race to claim the right.
Locks release; counters sing.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main mutex behavior change from lock handoff to barging.
Description check ✅ Passed The description matches the mutex barging change, futex path, benchmarks, and added tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch barging-mutex

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (2)
src/sync/Mutex.zig (2)

111-115: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

No spin-before-park in lockThread.

Every wake cycle here re-does an unconditional swap, then parks again on a miss — correct, but it's the exact swap-based futex loop that MutexFutex.lockSlow (os/thread.zig) precedes with a short spin to skip the syscall on brief contention. Worth mirroring that here for OS-thread contention, given the PR's stated goal of "reduced contention times... for OS threads."

♻️ Optional spin-before-park
 fn lockThread(self: *Mutex) void {
+    var spin: u32 = 0;
+    while (spin < 100) : (spin += 1) {
+        if (self.state.load(.monotonic) == unlocked) {
+            if (self.state.cmpxchgWeak(unlocked, thread_contended, .acquire, .monotonic) == null) return;
+        }
+        std.atomic.spinLoopHint();
+    }
     while (self.state.swap(thread_contended, .acquire) != unlocked) {
         thread_futex.wait(&self.state, thread_contended);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sync/Mutex.zig` around lines 111 - 115, Update Mutex.lockThread to add a
short bounded spin-before-park phase, mirroring the established approach in
MutexFutex.lockSlow. Retry the state acquisition during spinning, and call
thread_futex.wait only after the spin fails, while preserving the existing
contended/unlocked state transitions.

135-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cancellation-vs-pop race path has no test coverage.

This branch — remove-from-queue fails because unlock already popped and signaled us, so we drain the pending wake and forward it to the next waiter — is the trickiest new logic in this rewrite (lost-signal-on-cancel is exactly the kind of bug that only shows up under real contention). None of the added tests (Mutex foreign threads, Mutex mixed tasks and threads) or the pre-existing ones exercise cancellation while parked in lockSlow.

Want me to draft a test that spawns a task blocked on lock(), cancels its group while another holder/waiter is churning the lock, and asserts no deadlock/lost wakeup?

Also applies to: 224-285

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sync/Mutex.zig` around lines 135 - 146, Add a concurrency test covering
the cancellation-vs-pop race in Mutex.lockSlow: block a task in lock(), cancel
its group while another holder or waiter unlocks and churns the mutex, and
verify the cancellation completes without deadlock or lost wakeup. Ensure the
test exercises the queue.remove failure path and confirms subsequent waiters can
still acquire the mutex.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/sync/Mutex.zig`:
- Around line 111-115: Update Mutex.lockThread to add a short bounded
spin-before-park phase, mirroring the established approach in
MutexFutex.lockSlow. Retry the state acquisition during spinning, and call
thread_futex.wait only after the spin fails, while preserving the existing
contended/unlocked state transitions.
- Around line 135-146: Add a concurrency test covering the cancellation-vs-pop
race in Mutex.lockSlow: block a task in lock(), cancel its group while another
holder or waiter unlocks and churns the mutex, and verify the cancellation
completes without deadlock or lost wakeup. Ensure the test exercises the
queue.remove failure path and confirms subsequent waiters can still acquire the
mutex.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 643676e7-1368-494e-a27d-67e6508dd3ca

📥 Commits

Reviewing files that changed from the base of the PR and between da263fe and 60f9e80.

📒 Files selected for processing (2)
  • src/os/thread.zig
  • src/sync/Mutex.zig

@lalinsky

Copy link
Copy Markdown
Owner Author

@coderabbitai Measured the spin-before-park suggestion before adopting it: 16 interleaved runs of the contended counter (4 OS threads, 100k locks each), spin done exactly like MutexFutex's 100-iteration pattern.

med min max futex calls
with spin 35.1 ms 21.4 47.5 361
without 13.1 ms 12.0 58.8 581

The spin does what it promises — parking drops by ~40% — and still loses by ~2.7x. With critical sections this short, spinners keep the state word's cache line bouncing between cores, which slows down the holder's unlock and the next acquire more than the saved syscalls are worth; parking takes waiters off the line. Same effect that led to spinning being removed from std.Thread.Mutex. So lockThread stays spin-free.

The cancellation test suggestion was a good catch: added in a28e5f9.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@lalinsky Data settles it — 2.7x is not a close call, and the mechanism you describe (cache-line ping-pong from spinners fighting the holder on short critical sections) matches why std.Thread.Mutex dropped spinning too. Fewer futex calls isn't the metric that matters here, wall time is, and parking wins on that. Good call keeping lockThread spin-free.

Thanks for adding the cancellation test in a28e5f9.

🐇


✏️ Learnings added
Learnt from: lalinsky
URL: https://github.com/lalinsky/zio/pull/573

Timestamp: 2026-07-15T06:56:08.159Z
Learning: In zio's src/sync/Mutex.zig, the foreign-thread lock path (lockThread) intentionally does not spin before parking on the futex, despite MutexFutex-style spinning being a common pattern. Benchmark (4 OS threads, 100k locks each, 16 interleaved runs of a contended counter) showed spin-before-park (100 iterations) was ~2.7x slower than parking immediately (35.1ms vs 13.1ms median), even though it reduced futex syscalls by ~40% (361 vs 581 calls). With critical sections this short, spinning keeps the state word's cache line bouncing between cores, slowing down the holder's unlock and next acquire more than the saved syscalls are worth. This mirrors the rationale for removing spinning from std.Thread.Mutex.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@lalinsky
lalinsky merged commit 7d9ba5e into main Jul 15, 2026
27 checks passed
@lalinsky
lalinsky deleted the barging-mutex branch July 15, 2026 07:04
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