fix(sync): stop craft's locks busy-waiting - #80
Merged
Conversation
Contributor
Author
|
Reopening to trigger CI — no run was created on push. |
`compat_mutex` exists because `std.Thread.Mutex` and `std.Thread.Condition` were removed: the Io rework moved blocking primitives to `std.Io.Mutex` and `std.Io.Condition`, which take an `Io` because that is what knows how to wait. This file papered over that by spinning. `Mutex.lock` looped on `tryLock` with `spinLoopHint`, `Condition.wait` spun on a `u32` flag, and neither ever yielded. Measured: three threads waiting 300ms on a spun flag burn 896ms of CPU. Three cores held against whatever they are waiting to finish. Ten files and 88 lock sites are built on this, and #78 added ~200 more by routing every std.log call through a module that takes one of these locks across a write. Both real primitives are used now. The `Io` they need is handed to the module once from `io_context.init` rather than threaded through all 88 call sites, which would have been a signature change across ten files for something that is a property of the process rather than of any call. It is installed before `global_state` deliberately: `global_state` locks one of these mutexes to answer `io_context.get()`, so having the lock ask for the `Io` would recurse into the lock being taken. The spin path stays for everything that runs before startup finishes — tests, `--eval`, anything locking before `io_context.init` — but it yields now, so a waiter cannot hold a core against the holder. Mixing the two is safe in the only direction that occurs: `install` happens once and is never undone, so before it the only way to acquire is `tryLock`, which never reaches the `contended` state that unlock needs an `Io` to wake. One thing I could not demonstrate and am not claiming: the flag-based `Condition` also had a lost-wakeup race, where `broadcast` set one flag and the first waiter to observe it cleared it. A test written to catch that passed against the old code — spinning waiters poll often enough that they all see the flag first — so it is recorded as a reason and not as a fixed bug. The test is kept for the property, labelled as not being a regression test. These primitives had no tests, which is how they stayed spin-only through the Zig version change that made them so.
glennmichael123
force-pushed
the
fix/blocking-primitives
branch
from
August 27, 2026 14:47
3300ac4 to
9d7b1fe
Compare
✅ Binary load timeWhat this measures
Both binaries are measured interleaved on this runner and compared by |
✅ Binary Size Report
Size limits
|
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
The deferred item from #78.
compat_mutexis what every lock in craft is built on — 10 files, 88 lock sites — and it spins.What it costs
std.Thread.Mutexandstd.Thread.Conditionwere removed; the Io rework moved blocking primitives tostd.Io.Mutex/std.Io.Condition, which take anIobecause that is what knows how to wait. This file papered over that by spinning:Mutex.locklooped ontryLockwithspinLoopHint,Condition.waitspun on au32flag, and neither ever yielded.Measured, not asserted:
Three cores held against whatever they are waiting to finish. And #78 made this hotter, by routing ~200
std.logsites through a module that takes one of these locks across a write.The fix
Both real primitives, with the
Iohanded to the module once fromio_context.initrather than threaded through 88 call sites — that would be a signature change across ten files for something that is a property of the process, not of any call.Installed before
global_state, deliberately.global_statelocks one of these mutexes to answerio_context.get(), so having the lock ask for theIoitself would recurse into the lock being taken.The spin path stays for anything running before startup finishes — tests,
--eval, anything locking beforeio_context.init— but it yields now, so a waiter cannot hold a core against the holder.Mixing the two paths is safe in the only direction that occurs.
installhappens once and is never undone, so:tryLock, which movesunlocked → locked_onceand never tocontended— an unlock with noIotherefore has no waiter to wake, and none can existIoA lock taken before and released after is fine: the state is
locked_onceeither way, andunlockwakes nobody.What I could not demonstrate, and am not claiming
The flag-based
Conditionalso had a lost-wakeup race —broadcastset one flag and the first waiter to observe it cleared it. I wrote a test to catch that and it passed against the old code, because spinning waiters poll often enough that they all see the flag before any clears it. It is a race, not a certainty.So it is recorded in the file as a reason, not as a fixed bug, and the test is kept for the property it asserts with a comment saying explicitly that it is not a regression test. The CPU measurement is the part that reproduces.
Tests
These primitives had none — which is how they stayed spin-only across the Zig version change that made them so. Five now: mutual exclusion, the pre-
Iopath, the before/after mixing case, a 4-thread × 2000-iteration counter that would not add up under a lost update, and broadcast reaching every waiter.Also verified the binary still runs correctly under the new locks —
--log-fileoutput is byte-identical to before the change.Verified
zig build·zig build test·zig build test-js·x86_64-windowscross-build ·zig fmt --check· 511 bun tests · pickier 38 warnings, unchanged from main.