Tutorials › Launch Lab Notes › Chapter 11

The Wrong Total: A Data Race You Can Read Off the Screen

LabChapter 11 of the Launch Lab Notes22 minAugust 19, 2026Intermediate

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.

Six frames: both panels show 0 while HAMMERING across runs, then a settled frame where the class reads 1,618 lost 8,382 and the actor reads 10,000 exact.

The setup is fair on purpose

static let workerCount = 20
static let creditsPerWorker = 500
static var creditTotal: Int { workerCount * creditsPerWorker } // 10_000

Twenty 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.

Settled run 1: CLASS shows 1,330 with lost 8,670; ACTOR shows 10,000 exact; EXPECTED 10,000; 20×500.
LedgerTotalNote
Class (control)1,330lost 8,670
Actor10,000exact
Expected10,00020 × 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.

The wrong number moves. The right one does not.

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

  1. Replace the spin with await Task.yield() between read and write and measure whether the lost count grows or shrinks on this device.
  2. Protect RacyLedger with an OSAllocatedUnfairLock and photograph both panels at 10,000 — then remove the lock and watch lost return.
  3. Mark credit on the actor as nonisolated incorrectly and collect the compiler errors; write down which ones map to the race the class allows at runtime.
  4. 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.
  5. 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

Next up: Combine vs async/await — the same debounced search twice, with an event tape showing what each emitted and when.

SwiftUI
SwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.
Swift
Swift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.
Ship iOS
Shipping workflows for iOS apps.
📚 Go deeper with LIPAI WANG’s hands-on Udemy bootcampsBrowse all courses →
← Ch 10: Who Keeps WorkingCh 12: Two Debounces, One Tape→
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.

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
5 free articles remainingSubscribe for unlimited access