Swift Concurrency

Avoiding Data Races in Swift

Let’s dive into the slippery world of data races, a problem that creeps up when threads, those tiny units of concurrent work, clash over the same piece of data. Imagine you’re spinning up multiple threads, each one eager to reach the same target variable like kids racing to hit the last cookie in the jar—each wanting to read, modify, and update a shared value. Without proper control, this leads to chaotic and unpredictable outcomes.

We’ll explore what makes race conditions happen, why they’re tricky, and how we can tame them—keeping things grounded in relatable examples along the way.

What’s a Data Race, and Why Does It Matter?

In multithreading, data races occur when multiple threads try to read and write a shared variable at the same time, resulting in unpredictable or inconsistent data. Consider this simple setup, Imagine you’re building a video streaming app that tracks viewer counts:

final class ViewerCounter {
    var count = 0
}
let counter = ViewerCounter()

Now we’ll simulate a situation where we spin up 1,000 threads, each tasked with incrementing the count by 1. Intuitively, after all the threads have completed, we’d expect the final view count to be 1,000, right?

// Called from multiple threads as viewers join/leave
for _ in 0..<1_000 {
    Thread.detachNewThread {
        Thread.sleep(forTimeInterval: 0.01)
        counter.count += 1
    }
}
Thread.sleep(forTimeInterval: 0.5)
print("views count", counter.count)

The problem? When we actually run this code, we’ll see a final count that’s almost always less than 1,000—maybe around 987 one time, then 993, then 971. Why?

This is happening because mutating a field does not happen in one single atomic CPU instruction, but rather many instructions. Multiple threads are running those same instructions, and so the instructions will start to become interleaved, allowing for the possibility of one thread overwriting the results of another. Behind the scenes, it’s a sequence of three distinct steps:

  1. Read the current value of count.
  2. Increment the value by 1.
  3. Write the new value back to count.

Each thread might begin the process at the same time, resulting in overlapping and interleaving reads, increments, and writes. And when these steps interleave, chaos begins.

How Interleaving Leads to Races

To make this clearer, let’s imagine two threads, Thread 1 and Thread 2, are racing to increment count, which starts at 0. Both threads execute the same sequence:

Here’s how two threads might interleave these operations:

// Thread 1                    // Thread 2
var temp1 = counter.count(0)   
                              var temp2 = counter.count(0)
temp1 = temp1 + 1(1)          
                              temp2 = temp2 + 1(1)
counter.count = temp1(1)       
                              counter.count = temp2(1)
  1. Thread 1 reads count and sees 0.
  2. Thread 2 reads count and also sees 0.
  3. Thread 1 increments its copy of count to 1.
  4. Thread 2 increments its copy of count to 1.
  5. Thread 1 writes 1 back to count.
  6. Thread 2 writes 1 back to count.

After both threads have “incremented” count, the value is only 1 instead of 2! Each thread effectively overwrote the other’s work, and this is a data race at its core.

This example might seem trivial, but in real-world applications, data races can cause bugs that are very difficult to detect and replicate because they’re often inconsistent, only appearing under specific timing conditions.

Introducing Locks: The Bouncers of Our Data

To fix this we need to synchronize access so that when we start mutating the field we can block all other threads from starting their mutations, and then only once we are done with our mutation will we allow other threads to enter. This is where locks come in. Locks act like bouncers at a club, ensuring only one thread can access a shared resource at a time. In Swift, we can use NSLock to synchronize access to count.

Let’s rewrite our Counter class to use a lock:

final class ViewerCounter {
    let lock = NSLock()
    private(set) var count = 0

    func increment() {
        self.lock.lock()
        defer { self.lock.unlock() }
        self.count += 1
    }

 	func decrement() {
        self.lock.lock()
        defer { self.lock.unlock() }
        self.count -= 1
    }
}

By wrapping the increment operation within lock and unlock calls, we ensure that only one thread at a time can perform the “read, increment, write” sequence. Each thread has to wait its turn to lock the code, increment count, and then unlock the code before the next thread can step in.

When we run this code:

for _ in 0..<1_000 {
    Thread.detachNewThread {
        Thread.sleep(forTimeInterval: 0.01)
        counter.increment()
    }
}

We’ll see the count consistently reach 1,000, as expected.

Making Thread Safety More Convenient

But there’s one downside here. Wrapping every single access to shared data in lock and unlock calls is tedious and error-prone. Forgetting to unlock, or unlocking too early, can cause deadlocks or incorrect behavior. To make things cleaner, we can introduce a modify method that handles the locking for us:

func modify(work: (ViewerCounter) -> Void) {
    self.lock.lock()
    defer { self.lock.unlock() }
    work(self)
}

Now, we can safely increment count without having to manually lock and unlock each time:

counter.modify {
    $0.count += 1
}

This approach is easier to read and less error-prone.

Why Property-Level Locking Falls Short

It’d be nice if we could make the count property thread-safe all by itself. Ideally, we could just write:

counter.count += 1

and have it be thread-safe automatically. We might try adding a get and set method to handle locking on the property level:

private var _count = 0
var count: Int {
    get { // Called when reading value
        self.lock.lock()
        defer { self.lock.unlock() }
        return self._count // Returns a copy
    }
    set { // Called when assigning new value
        self.lock.lock()
        defer { self.lock.unlock() }
        self._count = newValue // Assigns the new value
    }
}

This technically works, but it doesn’t solve our problem. Why? Because each operation (read and write) is still separate. With our earlier sequence of “read, increment, write,” the read and write portions can still interleave with those of another thread. Property-level locking is great for simple reads and writes, but it doesn’t work for transactions that require multiple steps. This is happening because we are only locking the getting and setting portion of the mutation, but not the entire transaction.

Let’s assume we have two threads, Thread A and Thread B, both running our count += 1 operation. Here’s what could happen in practice: if Thread A and Thread B both execute count += 1, they’ll interleave like this: 1 Thread A:

  • Acquires the lock to get the count (say it’s 5).
  • Releases the lock after reading the value.
  • Increments 5 to 6 (outside of the lock). 2 Thread B (sneaks in during this gap):
  • Acquires the lock to get the count (it also sees 5, because Thread A hasn’t written its incremented value back yet).
  • Releases the lock after reading.
  • Increments 5 to 6 independently. 3 Thread A (continues):
  • Acquires the lock to set the incremented value (6).
  • Releases the lock after writing 6. 4 Thread B (continues):
  • Acquires the lock to set the incremented value (6 again).
  • Releases the lock after writing 6.

At this point, both threads have finished their count += 1 operations. However, count is now 6 instead of 7, because both threads read 5 independently, and each wrote back 6. The critical problem: There are gaps between these operations where other threads can sneak in.

Using Read-Modify-Yield: The Smoother Approach

in previous Article we discussed we discussed Swift’s lesser-known feature for thread safety: _read and _modify accessors. They are specialized accessors designed to optimize read and write operations by yielding control over the value in a way that’s potentially safe for multi-threaded access but doesn’t necessarily lock the entire transaction. They’re designed more as tools to make access efficient, not to guarantee atomic operations by themselves.

Understanding _read and _modify

The key difference from get/set is the efficiency - no need to make copies. It’s “yielding” (giving direct access) to the actual storage location rather than working with copies.

1 _read:

  • Used when you want to read a value
  • Instead of copying the value, it gives direct access to read it
  • Like looking at something without taking a snapshot

2 _modify:

  • Used when you want to change a value
  • Gives direct access to change the original value
  • Like changing something in place without making copies

Here’s how it would look:

var count: Int {
    _read { // Called when only reading
        self.lock.lock()
        defer { self.lock.unlock() }
        yield self._count // Hands off direct access to _count
    }
    _modify {// Called when modifying vale
        self.lock.lock()
        defer { self.lock.unlock() }
        yield &self._count // Gives direct access to _count
    }
}

When we do counter.count += 1:

  • Instead of get/set, Swift uses _modify, we only call _modify because we’re modifying the value. We dont need to read the vlaue first because now we have direct access to it.
  • The & in yield &_count means “here’s direct access to modify this value”
  • The modification happens directly on _count

Think of yield like saying “here, use this directly” instead of “here’s a copy.” The key difference is directness - get/set works with copies, while _read/_modify works directly with the storage. However, both still have the same threading issues with complex operations. Example if we try something more complex, like counter.count += 1 + counter.count / 100, it won’t be fully atomic. The threads might interleave between the first and second reference to count, leading to unpredictable results. Because for something like just reading the value:

let x = counter.count  // This calls _read

And for compound operations that both read and modify:

counter.count += counter.count / 100

it calls:

1 _read to get the current count value
2 _modify to perform the update

So _read is used when we’re only reading the value, while_modifyis used when we’re changing it. That’s why even with these accessors, compound operations can still have race conditions - because they need both a read and a modify, which creates a gap where other threads can interfere. This is clearer than get/set because:

  • _read: Direct access for reading.
  • _modify: Direct access for modifying.
  • No intermediate copies needed as in get/set pattern.

But it still doesn’t solve the fundamental threading problem for operations that need both read and modify steps. That’s why we need the explicit modify pattern for those cases as we will see in the next section.

Embracing modify Method for Complex Transactions

The most reliable way to ensure thread safety for multi-step operations is still using a dedicated modify method, which allows us to wrap the entire transaction in a lock:

counter.modify {
    $0.count += 1 + $0.count / 100 // // All under one lock
}

The key is that the entire operation happens under a single lock, so no other threads can interfere. This is more reliable than property-level locking (_read/_modify) for compound operations because there are no gaps where other threads can sneak in.

Wrapping Up: Why Thread Safety Is Tricky

Achieving reliable thread safety isn’t just about adding locks—it’s about understanding where and how to use them, especially when dealing with shared state across multiple threads. As our example shows, naïve solutions to data races (like locking properties individually) often fall short for complex operations. Instead, we need approaches that handle whole transactions to truly prevent interleaving issues.

It’s a balance between locking down access tightly enough to avoid races while keeping the code manageable and efficient. Multithreading is powerful, but it’s also a puzzle—one that requires precise moves to solve.