--- a/stats.swift +++ b/stats.swift @@ -1,31 +1,36 @@ -// EXERCISE 02 — BROKEN STARTING POINT. Do not edit this file; copy it. +// EXERCISE 02 — FIXED VARIANT. The repair is one critical section around the +// whole read-modify-write, and a type the compiler can check. // -// ============================ UNSAFE CODE WARNING ============================ -// DownloadStats below contains a DELIBERATE data race. It is marked -// `@unchecked Sendable` purely to stop Swift 6 strict concurrency checking from -// rejecting it, so the bug can be demonstrated. That annotation is a promise to -// the compiler that you have synchronised the type yourself; here the promise -// is a lie, on purpose. Never write @unchecked Sendable to quiet a diagnostic. -// ============================================================================= -// // THE SCENARIO // A download manager tallies bytes and chunks from several transfer threads. -// The totals shown in the UI are "a bit low, but only on fast connections". // -// Build (plain): swiftc -swift-version 6 -Onone stats.swift -o stats_broken +// THE REPAIR +// The two counters move inside a single `Mutex` (Synchronization, macOS 15+), +// which owns them. There is no way to read or write either counter without +// holding the lock, because `withLock` is the only door. `@unchecked +// Sendable` is gone with it: `Mutex` is `Sendable` on its own terms, so the +// compiler checks this type rather than taking our word for it. +// +// Note what did NOT change: the workload, the thread count, the iteration +// count. Only the boundary around the mutation moved. +// +// The two counters are deliberately kept in ONE Mutex rather than two. Two +// independent locks would still make each counter individually correct while +// letting a reader observe a byte total that does not match the chunk total. +// +// Build (plain): swiftc -swift-version 6 -Onone stats.swift -o stats_fixed // Build (TSan): swiftc -swift-version 6 -Onone -g -sanitize=thread \ -// stats.swift -o stats_broken_tsan -// Run: ./stats_broken +// stats.swift -o stats_fixed_tsan +// Run: ./stats_fixed // -// Expected: the plain build prints correct=false and a nonzero `lost` count -// that CHANGES BETWEEN RUNS. The sanitized build additionally prints -// "WARNING: ThreadSanitizer: ..." on stderr. Exit status is 0 either way — a -// data race is not a crash, which is exactly what makes it dangerous. +// Expected: correct=true on every run, with lostChunks=0, and no Thread +// Sanitizer output at all. // // Bounded: fixed iteration counts, no blocking waits, plus a watchdog thread // that force-exits after WATCHDOG_S. import Foundation +import Synchronization let WATCHDOG_S: Double = 60 let EXIT_WATCHDOG: Int32 = 75 @@ -44,23 +49,25 @@ // MARK: - the shared tally -/// Two counters updated from every transfer thread. +/// Two counters updated from every transfer thread, behind one lock that owns +/// them both. /// -/// `bytesReceived += bytes` is not one instruction. It is a load, an add and a -/// store, and nothing here stops a second thread from loading the same value -/// between our load and our store. That second thread's update is then -/// overwritten and gone. -final class DownloadStats: @unchecked Sendable { // <-- UNSAFE BY DESIGN - var bytesReceived = 0 - var chunksReceived = 0 +/// `bytesReceived += bytes` is still a load, an add and a store. The difference +/// is that the whole sequence now happens inside a critical section, so no +/// other thread can load the same value between our load and our store. +final class DownloadStats: Sendable { + private struct Totals { var bytes = 0; var chunks = 0 } + private let totals = Mutex(Totals()) func record(bytes: Int) { - bytesReceived += bytes - chunksReceived += 1 + totals.withLock { t in + t.bytes += bytes + t.chunks += 1 + } } func snapshot() -> (bytes: Int, chunks: Int) { - (bytesReceived, chunksReceived) + totals.withLock { ($0.bytes, $0.chunks) } } } @@ -84,13 +91,13 @@ return stats.snapshot() } -armWatchdog(WATCHDOG_S, "EX02 build=broken") +armWatchdog(WATCHDOG_S, "EX02 build=fixed") let expectedChunks = threads * perThread let expectedBytes = expectedChunks * chunkBytes let observed = runTransfers() -print("EX02 build=broken threads=\(threads) perThread=\(perThread) " +print("EX02 build=fixed threads=\(threads) perThread=\(perThread) " + "expectedChunks=\(expectedChunks) observedChunks=\(observed.chunks) " + "lostChunks=\(expectedChunks - observed.chunks) " + "expectedBytes=\(expectedBytes) observedBytes=\(observed.bytes) "