Thread Sanitizer exposes shared mutation in Linux Swift
Swift 5.1 development snapshots bring Thread Sanitizer to Linux. Runtime instrumentation finds conflicting accesses that value semantics alone cannot prevent.
An array can be a value type while several threads still mutate the same array variable. That distinction is central to Thread Sanitizer support for Swift on Linux, announced on August 13. The tool is available in Swift 5.1 development snapshots. It instruments the program at compilation and reports data races encountered during execution.
The announcement uses concurrent array appends to demonstrate the problem. Computing separate results in parallel does not make appending them to one shared variable safe. The shared collection remains a point where the workers access the same mutable storage. A language that helps control ordinary local mutation still needs a way to diagnose that concurrent access.
I like Swift's value model because it makes many mutations local. This example marks a boundary of that benefit. A value type does not provide a synchronization policy for a variable captured by concurrent work.
Separate computation from shared collection
Consider a batch operation where each worker produces an integer. The computation can be independent, but gathering those integers into one array introduces a shared operation. The following example adapts the synchronization approach shown in the announcement.
import Dispatch
var results = [Int]()
let resultQueue = DispatchQueue(label: "example.results")
DispatchQueue.concurrentPerform(iterations: 8) { index in
let result = index * index
resultQueue.sync {
results.append(result)
}
}
assert(results.count == 8)
The serial queue protects the append operation. Each worker calculates its result before entering that queue, so serializing collection does not require serializing the calculation. Moving the calculation into the synchronized closure would change that division of work and limit the opportunity for parallel execution.
The resulting array does not promise input order. Workers can reach the collection step in different orders, and a serial queue preserves the order of the work submitted to it rather than reconstructing the original iteration sequence. That is a separate application requirement. A caller needing ordered output must retain enough information to associate each result with its input position.
The distinction is useful because a correct count is not a complete correctness test. The array can contain every expected result while placing them in an order the caller did not intend. Conversely, one run producing the intended order cannot establish that another schedule will do the same. Synchronization and ordering have related implementations but different requirements.
Compile the code that the diagnostic will observe
The announcement gives commands for both direct compiler invocation and Swift Package Manager. A package test run uses the sanitizer through the test subcommand.
swift test -c debug --sanitize=thread
For a standalone source file, the corresponding invocation includes debug information and the thread sanitizer flag.
swiftc main.swift -g -Onone -sanitize=thread -o race
Debug information allows reports to identify source locations. The Swift announcement recommends unoptimized code for this support, so the explicit -Onone above prevents an inherited optimization setting from changing that configuration. Instrumentation has to be part of the build. Running an ordinary executable under the same command name does not retroactively add the compiler's access tracking.
The Clang 8 ThreadSanitizer documentation describes the underlying division between compiler instrumentation and a runtime library. Its resource estimates also show why a sanitizer run has different operating costs from a normal execution. The documentation reports typical runtime slowdown of five to fifteen times and memory overhead of five to ten times. Those are tool estimates, not measurements of this Swift example.
A diagnostic build therefore belongs to its own execution configuration. Its elapsed time cannot be interpreted as ordinary application performance. Its additional memory use also means that the size of a useful race-detection workload may differ from the size of a normal test workload.
Read both accesses in a report
A data race involves unsynchronized accesses from different threads to the same memory location, with at least one write. The Swift report includes the conflicting accesses and their stack traces. It can also show where the threads were created. That gives an investigation both ends of the conflict instead of identifying only the point where corrupted data later becomes visible.
This changes how a failure can be approached. A crash site may contain a read of already corrupted state, while the operations responsible happened earlier. A race report can identify the competing accesses directly. The relevant repair then concerns how those operations coordinate, rather than merely adding a check at the later crash site.
A minimized reproduction can preserve that relationship without retaining the whole application. The important ingredients are the shared storage and the paths that access it. Removing an apparently unrelated callback may remove one participant in the race, so simplification has to preserve the conflicting accesses identified in the report.
Two accesses can originate from the same source statement executed by separate workers. The source line is therefore only part of the explanation. The thread and calling context establish how separate executions reached the same storage. A source statement that looks harmless in isolation can violate the program's sharing assumptions when several invocations overlap.
I find the paired traces particularly valuable because concurrency defects are relationships between operations. A diagnostic that reports only one operation leaves the other participant to be inferred. Seeing both paths makes the missing coordination concrete.
Coverage determines which races can be found
Thread Sanitizer diagnoses executed behavior. A package test that never starts concurrent work cannot reveal the concurrent append problem. A test that exercises only initialization cannot establish anything about a later callback updating shared state. The tool adds observations to a run, so the run still needs to reach the relevant interactions.
This limitation also distinguishes race detection from a proof of thread safety. An execution without a report supplies evidence about the accesses observed under that workload. It does not establish that every possible callback or scheduling relationship is safe. The design still needs a clear account of which component owns each mutable value and how other components obtain access to it.
For the array example, that account is straightforward. Workers own their intermediate integers, and one serial queue controls modifications to the shared result array. Any later code that accesses the array concurrently must follow the same coordination rule. Protecting one append site while leaving another write outside the queue would leave the ownership contract incomplete.