Zig's defer ends at the closing brace
Contents
Assumes you have guarded a critical section with a mutex and defer unlock in Go.
Moving money between two accounts under per-account locks has one famous trap, and it is the order. Lock A then B in one thread, B then A in another, and the two transfers deadlock. The textbook answer is to sort the IDs and always lock the lower one first. It is in every concurrency chapter.
Swift 1.5, a 27B local model and the one this benchmark setup recommends for daily coding, got the order right. Its Zig transfer sorted the account IDs, locked low before high, and never deadlocked. The scorer gave the run 97 points out of 100 and a FAIL. Five of the six accounts in the stress test ended up short.
The second lock was held for zero statements.
The diff: two ordered locks, one of them held#
The task (t15-ledger-zig in a local agent benchmark) asks for an idempotent, atomic, thread-safe transfer on a small Zig 0.16.0 ledger service. The model worked through OpenCode. This is the locking block from its diff:
+ const low_mutex = try self.lockFor(low_id);
+ const high_mutex = try self.lockFor(high_id);
+ low_mutex.lockUncancelable(self.io);
+ defer low_mutex.unlock(self.io);
+ if (low_id != high_id) {
+ high_mutex.lockUncancelable(self.io);
+ defer high_mutex.unlock(self.io);
+ }
Read it as Go and it is correct. The guard keeps a self-transfer from locking the same mutex twice, and both unlocks wait for the function to return. Read it as Zig and high_mutex is unlocked by the } on the very next line. Every load and store after that, on the higher-numbered account, runs with only the lower account's lock held.
The compiler has no opinion about any of this. zig fmt is clean, the build is clean, and the model's own fifteen tests pass.
Zig's defer is block-scoped, Go's is function-scoped#
Both languages document this plainly. The Zig 0.16.0 language reference opens its defer section with one sentence:
Executes an expression unconditionally at scope exit.
The first example under it is a bare block, { defer a = 2; a = 1; }, followed by an assertion that a is 2 once the block closes. A block is a scope, and so is the body of an if. Its second example even puts a defer inside if (false) { ... }, with the comment "defers are not run if they are never executed."
The Go spec says the opposite, and its example is the exact idiom at stake:
A "defer" statement invokes a function whose execution is deferred to the moment the surrounding function returns
lock(l)
defer unlock(l) // unlocking happens before surrounding function returns
The same keyword, spelled the same way, used for the same lock-then-unlock idiom, with two different scopes. The Go muscle memory of "lock, defer unlock, carry on" transplants into Zig everywhere except inside a nested block.
The same lines in Go keep every deposit#
The benchmark run is one failure under one stress test, so the shape went into a minimal repro. Two accounts start at 1,000,000. One thread makes 1,000,000 transfers of 1 from account 0 to account 1. A second thread makes 1,000,000 deposits of 1 into account 1, under account 1's lock only, the same way the ledger's deposit works. Account 1 should end at 3,000,000. There is no sleep or yield widening the window. Both programs ship with this post: main.zig for Zig 0.16.0 (zig build-exe main.zig -O Debug), and main.go with its go.mod for Go 1.27.1.
The Zig version has two transfers: the model's shape, and the same guard moved inside the defer:
// The shape from the run: the second lock's defer lives inside the if block.
fn transferBuggy(l: *Ledger, from: usize, to: usize, amount: i64) void {
const low = &l.accounts[@min(from, to)];
const high = &l.accounts[@max(from, to)];
low.mutex.lockUncancelable(l.io);
defer low.mutex.unlock(l.io);
if (low != high) {
high.mutex.lockUncancelable(l.io);
defer high.mutex.unlock(l.io);
}
l.move(from, to, amount);
}
// Same guard, moved inside the defer instead of around it.
fn transferFixed(l: *Ledger, from: usize, to: usize, amount: i64) void {
const low = &l.accounts[@min(from, to)];
const high = &l.accounts[@max(from, to)];
low.mutex.lockUncancelable(l.io);
defer low.mutex.unlock(l.io);
if (low != high) high.mutex.lockUncancelable(l.io);
defer if (low != high) high.mutex.unlock(l.io);
l.move(from, to, amount);
}
Built in Debug mode, as the benchmark scores, and run five times in a row:
buggy: account 1 = 2982592, want 3000000, missing 17408
fixed: account 1 = 3000000, want 3000000, missing 0
buggy: account 1 = 2986943, want 3000000, missing 13057
fixed: account 1 = 3000000, want 3000000, missing 0
buggy: account 1 = 2987264, want 3000000, missing 12736
fixed: account 1 = 3000000, want 3000000, missing 0
buggy: account 1 = 2985531, want 3000000, missing 14469
fixed: account 1 = 3000000, want 3000000, missing 0
buggy: account 1 = 2986587, want 3000000, missing 13413
fixed: account 1 = 3000000, want 3000000, missing 0
Not flaky: in those five runs the buggy shape lost between 12,736 and 17,408 updates every time, and the fixed one lost none. The count moves from run to run; the zero for the fixed version does not.
The Go version is a line-for-line transliteration, mine rather than the model's, if low != high { high.mu.Lock(); defer high.mu.Unlock() } included:
go: account 1 = 3000000, want 3000000, missing 0
go: account 1 = 3000000, want 3000000, missing 0
go: account 1 = 3000000, want 3000000, missing 0
go: account 1 = 3000000, want 3000000, missing 0
go: account 1 = 3000000, want 3000000, missing 0
A -race build prints the same line and no DATA RACE report. In Go this code has no race to find.
Why the other concurrency tests passed#
A lock that protects nothing for a whole function body failed exactly one test. It hid behind a second lock. Before taking the account locks, the model's transfer takes a service-wide idempotency_mutex, with a function-scoped defer, and holds it to the end:
+ self.idempotency_mutex.lockUncancelable(self.io);
+ defer self.idempotency_mutex.unlock(self.io);
So no two transfers ever run at the same time, whatever their account locks do. The model's own concurrency test, "concurrent transfers between two accounts lose nothing", starts eight threads that make one transfer each and nothing else, and it is right: they lose nothing. The hidden test for opposing transfers passed for the same reason, and so did the one for concurrent calls sharing an idempotency key.
deposit is the fixture's code and takes only the account's own mutex. The test that mixes them, randomTransfersWithDepositsConserveAndMatchSuccesses, runs eight threads over six accounts, and every tenth operation is a deposit. Here, for the first time in the suite, something writes the higher-numbered account while a transfer holds only the lower lock:
zz-hidden: account ACC-000002 = 1055.14 EUR, want 1059.84 EUR from its successful operations
zz-hidden: account ACC-000003 = 1009.79 EUR, want 1021.85 EUR from its successful operations
zz-hidden: account ACC-000004 = 1046.71 EUR, want 1061.13 EUR from its successful operations
zz-hidden: account ACC-000005 = 998.89 EUR, want 1014.77 EUR from its successful operations
zz-hidden: account ACC-000006 = 913.01 EUR, want 938.50 EUR from its successful operations
72.55 EUR short across five accounts. Look at which account is missing from the list. ACC-000001 has the lowest ID, so in every pair it belongs to, it is the low lock, the one whose defer lives at function scope. Every write to it happened under its mutex, and it is the one balance that came out right. The failure report is a map of which lock was real.
The second run: the guard was dead code#
Twelve Swift 1.5 runs of this task have diffs on disk. In ten of them, no unlock defer sits inside a nested block. Two have the if block. The second came from a run with a test-first prompt:
+ if (from == to) return error.SameAccount;
+
+ const first, const second = if (@intFromEnum(from) <= @intFromEnum(to)) .{ from, to } else .{ to, from };
+ const mutex1 = try self.lockFor(first);
+ const mutex2 = if (first == second) mutex1 else try self.lockFor(second);
+ mutex1.lockUncancelable(self.io);
+ defer mutex1.unlock(self.io);
+ if (first != second) {
+ mutex2.lockUncancelable(self.io);
+ defer mutex2.unlock(self.io);
+ }
Five lines above the first lock call, from == to has already returned SameAccount. By the time the if runs, first != second is always true. The guard does nothing, the braces it brings along cost the second lock, and the idempotency mutex, taken right after, hid it again. Same verdict, 97/100 and FAIL, the same failing core test, 261.00 EUR short this time, and once again every account except ACC-000001.
What would have caught it#
The benchmark has a mutant for exactly this, added after the first run. halflock keeps a transfer's first account lock and turns the later ones into no-ops. Against the reference solution the stress test catches it. Against both of these runs the score sheet reads no (equivalent here): deleting the second lock changes nothing, because the model had already done it.
Zig 0.16.0 compiles the shape without a word. The Swift language, which has nothing to do with the model beyond the name, ships a warning for precisely this, in DiagnosticsSema.def:
WARNING(defer_stmt_at_block_end,none,
"'defer' statement at end of scope always executes immediately; "
"replace with 'do' statement to silence this warning", ())
That is the cheap check: a defer that is the last statement of its block runs the instant it is registered. Both runs had one.
The fix is one of two lines. Where the guard is real, put it inside the defer, as in the repro: defer if (low != high) high.mutex.unlock(l.io);. Where an earlier return already rules out the equal case, as in the second run, delete the if and lock both unconditionally. Either way, the brace goes.
Filed under: zig, go, concurrency, llm, local-llm
Sources#
- Zig 0.16.0 language reference: defer — "Executes an expression unconditionally at scope exit", with the block and
if (false)examples. - The Go Programming Language Specification: Defer statements — function-scoped defer and the
lock(l)/defer unlock(l)example. - Swift compiler
DiagnosticsSema.def— thedefer_stmt_at_block_endwarning text. - Repro sources, published with this post:
main.zig(Zig 0.16.0),main.goandgo.mod(Go 1.27.1). - Benchmark artifacts (local, not published): the
t15-ledger-zigscore sheets, model diffs and hidden-test logs for the two runs; the scorer'shalflockmutant.