Why you should stop using @ThreadSafe in Swift
A few months ago, while tracking down a stubborn runtime crash in an iOS project, I noticed the use of a @ThreadSafe property wrapper. At first glance, it promised a convenient shortcut — simply annotate your properties, and poof, your code is safe. It’s no wonder these wrappers became popular when property wrappers were first introduced in Swift, offering an elegant way to simplify thread safety. However, despite the promise, the crashes persisted. Intrigued, I reached out to some friends and colleagues, and they mentioned that many projects employ similar approaches — sometimes called @Atomic, @Synchronized, or other variants. That realization prompted me to dig deeper and write this article.
If you’ve ever been tempted by @ThreadSafe (or its counterparts) as a “quick fix” for concurrency, this post will show you why such wrappers are dangerously misleading — and how you can achieve true thread safety instead.
Concurrency in iOS: A Blessing and a Curse
In iOS development, concurrency can make your app feel smooth and responsive by handling tasks in parallel. However, misuse can lead to elusive issues:
- Random crashes
- UI assertion errors
- Data inconsistencies that only appear once in a thousand runs (if you’re “lucky”)
These issues often surface after your app is already in the hands of users, leaving you with cryptic analytics logs and frustrated App Store reviews. So, how do these concurrency bugs creep in, and why do quick fixes like @ThreadSafe fail to solve them?
A Popular — but Problematic — Solution
Here’s a simplified example of the kind of code pattern I observed (and that others confirmed seeing in their own codebases)
final class Counter {
@ThreadSafe var count: Int = 0
@ThreadSafe var sumOfCounts: Int = 0
func increment() {
count += 1
sumOfCounts += count
}
}At first glance, this might look harmless. Each property is marked with @ThreadSafe, so all reads and writes to that property should be protected, right? If you’re skeptical about whether this truly guarantees thread safety, you’re on the right track. Let’s see why.
Why @ThreadSafe Isn’t Always Safe
When we annotate a property with @ThreadSafe, all we’re really doing is isolating the read or write operation on that single property. The moment we exit that operation, there’s no guarantee the property’s value won’t change in the meantime—especially if our class has multiple properties that need to stay in sync.
Here’s a simplified version of what a @ThreadSafe property wrapper might look like:
@propertyWrapper
struct ThreadSafe<Value> {
private var value: Value
private let lock = NSLock()
init(wrappedValue: Value) {
self.value = wrappedValue
}
var wrappedValue: Value {
get {
lock.lock()
defer { lock.unlock() }
return value
}
set {
lock.lock()
value = newValue
lock.unlock()
}
}
}Rewriting Our Class with Multiple Locks
You might try expanding the lock idea: lock each property individually. Here’s a more explicit approach using two separate NSLocks:
import Foundation
final class Counter {
private let lockCount = NSLock()
private let lockSumOfCounts = NSLock()
var count: Int
var sumOfCounts: Int
init() {
self.count = 0
self.sumOfCounts = 0
}
func increment() {
// Emulate `count += 1`:
// 1) Read `count` under lock
lockCount.lock()
let oldCount = count
lockCount.unlock()
// 2) Write `count = oldCount + 1` under lock
lockCount.lock()
count = oldCount + 1
lockCount.unlock()
// Emulate `sumOfCounts += count`:
// 1) Read `sumOfCounts` under its lock
lockSumOfCounts.lock()
let oldSum = sumOfCounts
lockSumOfCounts.unlock()
// 2) Read `count` again (for the sumOfCounts calculation)
lockCount.lock()
let currentCount = count
lockCount.unlock()
// 3) Write `sumOfCounts = oldSum + currentCount` under lock
lockSumOfCounts.lock()
sumOfCounts = oldSum + currentCount
lockSumOfCounts.unlock()
}
}The Hidden Problem
Even though this looks more “locked down,” it involves five separate lock interactions. Once a lock is released, the value you just read can already be outdated. We also can’t rely on properties protected by different locks remaining in sync. Using separate locks for each property prevents simultaneous writes on one property but still leaves your class vulnerable to race conditions when multiple properties must stay consistent.
Safer Approaches to Concurrency
1. Use a Single Lock for the Whole Operation
A straightforward way to keep your properties consistent is to use a single lock that guards all relevant state:
import Foundation
final class Counter {
private let lock = NSLock()
private var count: Int
private var sumOfCounts: Int
init() {
self.count = 0
self.sumOfCounts = 0
}
func increment() {
lock.lock()
count += 1
sumOfCounts += count
lock.unlock()
}
func currentValues() -> (Int, Int) {
var values = (0, 0)
lock.lock()
values = (self.count, self.sumOfCounts)
lock.unlock()
return values
}
}Here, we lock once at the start of increment(), do all necessary reads and writes, and then unlock. Because all shared data is protected by the same lock, we avoid a situation where partial updates slip through.
2. Use a Dedicated Serial Queue
If your operation is CPU-heavy or needs to run asynchronously, consider using a serial DispatchQueue:
import Foundation
final class Counter {
private let queue = DispatchQueue(label: "com.example.counterQueue")
private var count = 0
private var sumOfCounts = 0
func increment() {
queue.async {
self.count += 1
self.sumOfCounts += self.count
}
}
func currentValues(completion: @escaping (Int, Int) -> Void) {
queue.async {
completion(self.count, self.sumOfCounts)
}
}
}A dedicated serial queue ensures only one piece of work is executed at a time, effectively avoiding data races without manually locking and unlocking.
3. Use Swift Concurrency (Actors)
Disclaimer: This approach requires Swift 5.5 or later, and a deployment target that supports Swift Concurrency (e.g., iOS 15+). If your project can’t yet adopt modern concurrency, the first two approaches remain valid.
If you’re able to take advantage of Swift Concurrency, actors can greatly simplify your thread-safety code:
actor Counter {
private var count = 0
private var sumOfCounts = 0
func increment() {
count += 1
sumOfCounts += count
}
func currentValues() -> (Int, Int) {
(count, sumOfCounts)
}
}Actors in Swift automatically protect their state from concurrent access. You don’t have to manually lock and unlock anything; Swift ensures that mutable state within the actor is accessed safely. This drastically reduces the risk of race conditions and lock-related bugs while keeping the code more readable.
Conclusion & Key Takeaways
- Property-Level Locking Isn’t Enough
Locking each property individually can lead to race conditions when multiple properties must remain consistent. - Use a Single Lock, Serial Queue, or Swift Actors
If a set of operations must be performed together atomically, consider using one lock, a dedicated serial queue, or Swift’s built-in concurrency features (actors). Each of these approaches ensures your data remains consistent across multiple properties, preventing the subtle concurrency bugs that arise when locking individual properties or relying on partial updates. - Aim for Logical Consistency
Concurrency is not just about preventing simultaneous reads or writes on individual variables; it’s also about maintaining the logical invariants of your data. - Be Mindful of Performance vs. Safety
Multiple, fine-grained locks can introduce complexity and even reduce performance. Often, a single well-placed lock, a serial queue, or an actor is both safer and simpler.
By avoiding quick fixes like @ThreadSafe and focusing on the bigger picture—logical consistency—you’ll be able to design concurrency that keeps your app stable, maintainable, and responsive. Concurrency is a powerful tool, but it demands a careful, holistic approach to prevent lurking runtime crashes and data inconsistencies.
Thanks for reading! If you have any questions, experiences, or tips of your own about concurrency in Swift, feel free to share in the comments.