What is 10,000 − 1,330?
That is not a riddle. It is the number this bench printed after twenty tasks tried to credit the same ledger ten thousand times. The class kept 1,330. It lost 8,670. The actor next to it kept every credit.
Chapter 10 was about work that should have stopped. This one is about updates that should have counted — and did not, because two writers met in the same unread-modify-write window.
The setup is fair on purpose
static let workerCount = 20
static let creditsPerWorker = 500
static var creditTotal: Int { workerCount * creditsPerWorker } // 10_000Twenty tasks. Five hundred credits each. One expected total. Both ledgers take the same hammer:
await withTaskGroup(of: Void.self) { group in
for _ in 0..<Self.workerCount {
group.addTask {
for _ in 0..<Self.creditsPerWorker {
racy.credit()
await safe.credit()
}
}
}
}The mint lane awaits an actor method. The rose lane calls into a class. That is the only difference that matters.
| Ledger | Total | Note |
|---|---|---|
| Class (control) | 1,330 | lost 8,670 |
| Actor | 10,000 | exact |
| Expected | 10,000 | 20 × 500 |
The control: a widened race
A tight total += 1 on Apple silicon often wins too often to photograph. The control widens the window on purpose:
/// `@unchecked Sendable` is the confession — we are lying to the compiler so
/// the race can happen on purpose.
final class RacyLedger: @unchecked Sendable {
private var total = 0
func credit(_ amount: Int = 1) {
let prior = total
// Widen the race window so lost updates show up in a still.
var spin = 0
for i in 0..<400 { spin &+= i }
_ = spin
total = prior + amount
}
}Two tasks read the same prior. Both write prior + 1. One update vanishes. Repeat that under twenty workers and you do not lose a few credits — you lose most of them. 8,670 of them, in the still above.
@unchecked Sendable means "I promise," not "it is safe"The compiler believes the class can cross task boundaries. The screenshot is the receipt that the promise was false. Production code that silences sendability the same way is making the same confession — usually without a second panel to catch it.
The actor: isolation instead of hope
actor SafeLedger {
private var total = 0
func credit(_ amount: Int = 1) {
total += amount
}
func balance() -> Int { total }
}Actor methods run one at a time against that instance. Callers await their turn. There is no shared read-modify-write window to widen, because there is no concurrent mutation of total. The still says exact. The filmstrip's settled frame on a later run still says 10,000.
Run 1 of the still: class 1,330. Run 3 on the filmstrip: class 1,618 (lost 8,382). Data races are not deterministic — that is why they pass code review. The actor column is boring in the best way: 10,000 every time.
What Thread Sanitizer would say
The Modern Concurrency corpus reaches for TSan here, and it is the right tool when the bug is invisible. This bench is the other half of that lesson: once you force the race to lose updates loudly, you do not need a diagnostic pane to believe it. Prefer actors (or locks, or serial queues) so TSan stays quiet — and so a screenshot never has to explain a missing 8,670.
Sendable is the type-system twin of that discipline: values that are safe to share, references that are not, actors that are shareable because they serialise mutation. The rose ledger is @unchecked precisely so we can refuse that discipline for one panel.
Mini-exercise
In RacyLedger.credit, delete the spin loop and write total += amount instead. Re-run capture. If the class suddenly lands near 10,000, you have not fixed concurrency — you have narrowed the race window until this phone wins more often. Put the spin back. The chapter's claim is the lost count, not a lucky pass.
Challenges
- Replace the spin with
await Task.yield()between read and write and measure whether the lost count grows or shrinks on this device. - Protect
RacyLedgerwith anOSAllocatedUnfairLockand photograph both panels at 10,000 — then remove the lock and watch lost return. - Mark
crediton the actor asnonisolatedincorrectly and collect the compiler errors; write down which ones map to the race the class allows at runtime. - Feed the same hammer to a
nonisolated(unsafe)property on an actor and show that the keyword can re-introduce the rose column's bug inside an actor type. - Add a third card that uses a serial
DispatchQueue— same expected total, same still layout — and compare its wall time to the actor under-slowmo.
Key Points
- A data race drops updates; this bench dropped 8,670 of 10,000 on the class panel.
- The actor panel hit 10,000 exact under the same 20 × 500 hammer.
- The control widens read-modify-write on purpose — a tight
+=can hide on Apple silicon. @unchecked Sendablesilences the compiler; it does not serialise memory.- Actor isolation means one job at a time on that instance — callers await their turn.
- Wrong totals jitter across runs (1,330 vs 1,618); exact totals repeat.
- TSan finds races you cannot see; a photographable wrong total finds the ones you should have feared.
- Sendable is the type-level version of the same rule: do not share mutable references across tasks without isolation.
Next up: Combine vs async/await — the same debounced search twice, with an event tape showing what each emitted and when.
Read next
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper