01 Β· INTRODUCTION
Multiple Threads: Race Conditions
The short answer
A race condition happens when the correctness of a program depends on which piece of concurrent work reaches a shared value first.
In What Is the Main Thread?, we imagined a thread as one single-file stream of instructions flowing towards a processor core.
Now imagine that the same application contains two of those streams.
EXECUTION DIAGRAM
THREAD 1 THREAD 2
Instruction A1 Instruction B1
β β
βΌ βΌ
Instruction A2 Instruction B2
β β
ββββββββββββ¬βββββββββββββββββββββ
βΌ
PROCESSOR CORES
The streams can progress during the same period of time.
If both streams work with separate values, this may be exactly what we want.
If both streams read and change the same value, their instructions can become interleaved in an order we did not expect.
That is where race conditions begin.
π‘ The Central Idea
Multiple instruction streams can reach the same mutable state in different orders.
If the result depends on that order, the program contains a race condition.
02 Β· TUTORIAL
One Line of Swift Is Several Operations
Consider one of the smallest changes we can make to a value:
CODE EXAMPLE
counter += 1
It looks like one indivisible action because it occupies one line of Swift.
The processor does not execute Swift source lines. It executes the machine instructions produced by the compiler.
Incrementing the counter therefore requires a sequence of operations that we can simplify to:
CODE EXAMPLE
1. Read the current value of counter
2. Add 1 to the value that was read
3. Write the new value back to counter
These operations are not automatically protected from another thread.
The operating-system scheduler can pause one thread between them and allow another thread to execute.
03 Β· TUTORIAL
How Two Increments Become One
Suppose the counter begins at zero.
CODE EXAMPLE
var counter = 0
Thread 1 and Thread 2 each intend to add one.
If the two increments complete safely, the final value should be 2.
One possible interleaving looks like this:
EXECUTION DIAGRAM
SHARED COUNTER = 0
THREAD 1 THREAD 2
Read counter: 0
β
β Read counter: 0
β β
Calculate 0 + 1 β
β β
β Calculate 0 + 1
β β
Write counter: 1 β
β
Write counter: 1
SHARED COUNTER = 1
Expected: 2
Actual: 1
Neither thread saw the other thread's new value.
Both threads read 0. Both calculated 1. Both wrote 1.
The second write replaced the first write with the same value, so one increment was lost.
This is called a lost update.
π‘ Important Terminology
Shared state is data that more than one part of the program can access.
Mutable state is data that can change.
Interleaving is the order produced when instructions from concurrent streams execute among one another.
Lost update occurs when one write replaces the result of another write before that result has been preserved.
04 Β· TUTORIAL
Why Is It Called a Race?
The two threads are not racing towards the end of their functions.
They are racing to read or change the shared value.
If Thread 1 completes its entire increment before Thread 2 reads the counter, the result is correct:
CODE EXAMPLE
Thread 1 reads 0
Thread 1 writes 1
Thread 2 reads 1
Thread 2 writes 2
If both threads read the counter before either one writes its result, an update can be lost:
CODE EXAMPLE
Thread 1 reads 0
Thread 2 reads 0
Thread 1 writes 1
Thread 2 writes 1
The source code is unchanged.
Only the timing and order of execution changed.
A correct program cannot depend on winning that race.
05 Β· TUTORIAL
Why the Bug Does Not Happen Every Time
The operating system continually schedules runnable threads onto the processor cores that are available.
The exact interleaving can change from one run to the next.
It can be affected by other applications, system activity, processor availability and the amount of work each thread performs.
During one run, the two increments may happen one after another and produce 2.
During another run, their read and write operations may overlap and produce 1.
Adding logging or stopping at a breakpoint can change the timing enough to hide the failure.
π‘ Why Race Conditions Are Difficult to Reproduce
The fault is not one permanently incorrect line of execution.
The fault is that several individually reasonable operations are allowed to occur in an unsafe order.
06 Β· TUTORIAL
Race Condition and Data Race Are Not Identical Terms
The terms are often used as though they mean the same thing, but they describe different levels of the problem.
A race condition exists when a program's correct result depends on the timing or ordering of concurrent work.
A data race occurs when concurrent execution accesses the same memory, at least one access is a write, and the accesses are not correctly synchronised.
Our unprotected counter contains both:
EXECUTION DIAGRAM
Two threads access the same counter
+
At least one thread writes to it
+
No synchronisation controls the access
β
βΌ
DATA RACE
The final answer depends on the timing
β
βΌ
RACE CONDITION
All data races must be removed.
A program can also contain a higher-level race condition without performing an unsafe simultaneous memory access. We will see that distinction shortly.
07 Β· TUTORIAL
The Shared Operation Must Become Serial
The application can still perform many kinds of work concurrently.
Only access to the shared counter must be controlled so that one complete increment finishes before the next one begins.
EXECUTION DIAGRAM
SAFE ACCESS TO THE COUNTER
Thread 1 enters
β
βββ read 0
βββ calculate 1
βββ write 1
β
βΌ
Thread 1 leaves
β
βΌ
Thread 2 enters
β
βββ read 1
βββ calculate 2
βββ write 2
β
βΌ
Final counter = 2
The read, calculation and write form one protected operation.
No other instruction stream can enter that operation halfway through.
This property is often called mutual exclusion: while one execution context is changing the state, the others must wait.
08 Β· TUTORIAL
Protecting the Counter with a Serial Dispatch Queue
Before Swift Concurrency, a common solution was to create a private serial dispatch queue for the shared state.
CODE EXAMPLE
final class Counter {
private let queue = DispatchQueue(label: "com.example.counter")
private var value = 0
func increment() {
queue.sync {
value += 1
}
}
func read() -> Int {
queue.sync {
value
}
}
}
The serial queue allows only one submitted closure to execute at a time.
Every read and write passes through the same queue, so one increment cannot be interleaved with another increment.
The counter is safe only while every access follows that rule.
If a future method touches value without using the queue, the protection has been bypassed. The design depends on every developer continuing to follow the convention.
09 Β· TUTORIAL
Protecting the Counter with an Actor
Swift actors make the protection part of the type.
CODE EXAMPLE
actor Counter {
var value = 0
func increment() {
value += 1
}
func read() -> Int {
value
}
}
The counter's mutable state is isolated inside the actor.
Actor-isolated operations are given serial access to that state. One call to increment() changes the value before another call can perform the same synchronous operation.
CODE EXAMPLE
let counter = Counter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<30_000 {
group.addTask {
await counter.increment()
}
}
}
print(await counter.read())
// 30000
The tasks can run concurrently.
The actor controls their access to its counter.
Code outside the actor cannot directly change the isolated property:
CODE EXAMPLE
counter.value += 1
// Error: actor-isolated property 'value' cannot be mutated
// from a nonisolated context
The compiler now helps maintain the boundary that the serial-queue version expressed as a programming rule.
This is one of the central ideas in What Is Swift Concurrency?: concurrency is not only about starting work. It is also about giving that work safe rules for sharing state.
10 Β· TUTORIAL
An Actor Can Remove the Data Race but Leave a Logical Race
Actor isolation protects memory access, but we must still decide which operations belong together.
Imagine an actor that stores one remaining launch seat:
CODE EXAMPLE
actor LaunchSeats {
private var remaining = 1
func hasAvailableSeat() -> Bool {
remaining > 0
}
func reserveSeat() {
remaining -= 1
}
}
Now two tasks perform a check and a reservation as separate calls:
CODE EXAMPLE
if await seats.hasAvailableSeat() {
await seats.reserveSeat()
}
Each individual actor method accesses the property safely.
However, the complete decision contains a gap between checking and reserving.
CODE EXAMPLE
Task 1 checks: one seat is available
Task 1 leaves the actor
Task 2 checks: one seat is available
Task 2 reserves the seat
Task 1 resumes and also reserves a seat
There is no unprotected simultaneous access to the actor's memory.
There is still a race condition because the result depends on which task acts after the separate check.
The fix is to place the decision and the change inside one actor-isolated operation:
CODE EXAMPLE
actor LaunchSeats {
private var remaining = 1
func reserveSeat() -> Bool {
guard remaining > 0 else {
return false
}
remaining -= 1
return true
}
}
One task now completes the complete check-and-change operation before another task can begin it.
π‘ Protect the Whole Decision
Protecting a property is not enough when a decision requires several related steps.
The check and the change must become one isolated operation.
11 Β· TUTORIAL
Actors Do Not Mean One Thread per Actor
An actor is not a thread.
An actor protects access to its isolated state. Swift schedules the tasks that call the actor using a pool of threads managed by its concurrency runtime.
The important guarantee is not that every actor method runs on one permanently assigned thread.
The guarantee is that actor-isolated state is accessed according to the actor's isolation rules.
CODE EXAMPLE
THREADS
Execution resources used to run instructions
TASKS
Units of asynchronous work scheduled by Swift
ACTOR
A boundary that isolates mutable state
12 Β· TUTORIAL
The Complete Mental Model
EXECUTION DIAGRAM
ONE APPLICATION PROCESS
β
βββ Thread 1 instruction stream
β
βββ Thread 2 instruction stream
β
βΌ
Both reach shared state
β
ββββββββββββββ΄βββββββββββββ
β β
βΌ βΌ
Uncontrolled access Isolated access
β β
βΌ βΌ
Timing determines result Complete operation
β executes serially
βΌ β
Race condition / βΌ
data race Predictable result
Concurrency creates several streams of work that can progress during the same period.
A race condition appears when the correct result depends on the order in which those streams reach shared state.
A data race is the unsafe memory access that occurs when concurrent execution reads and writes the same memory without correct synchronisation.
Serial queues can protect the state when every access follows the queue's convention.
Actors place the isolation boundary around the state and allow the compiler to enforce access to it.
The final design must protect the complete logical operation, not merely its individual reads and writes.
That is how multiple threads create race conditionsβand how Swift gives us the tools to remove them.
13 Β· TUTORIAL
What to Remember
π‘ What to Remember
1. Several threads create several instruction streams inside one running process.
2. The scheduler can interleave their machine instructions in different orders.
3. One line such as counter += 1 requires a read, a calculation and a write.
4. If two threads read the same old value, one of their updates can be lost.
5. A race condition exists when correctness depends on timing or execution order.
6. A data race is concurrent, unsynchronised access to the same memory when at least one access is a write.
7. Access to shared mutable state must be serialised.
8. A serial dispatch queue works only while every access follows its protection rule.
9. An actor isolates mutable state and lets the compiler enforce the boundary.
10. Related steps such as checking and changing a value must form one isolated operation.
14 Β· TUTORIAL
Frequently Asked Questions
What is a race condition?
A race condition is a defect in which the correct result depends on the timing or ordering of concurrent operations.
What is a data race in Swift?
A data race occurs when concurrent execution accesses the same memory without correct synchronisation, at least one of those accesses writes to the memory, and the accesses can overlap.
Why is counter += 1 not automatically safe?
The statement requires the current value to be read, changed and written back. Another thread can access the same value between those operations unless access is protected.
Can a race condition happen on a single-core processor?
Yes. The operating system can pause one thread and run another on the same core. Their instructions can still be interleaved in an unsafe order even though the threads are not executing at precisely the same instant.
Does an actor use its own thread?
No. An actor is an isolation boundary for state, not a dedicated thread. Swift schedules tasks that access actors onto runtime-managed threads.
Can an actor still contain a race condition?
An actor prevents unprotected access to its isolated state, but a logical race can remain when one decision is divided across several actor calls. Keep the complete check-and-change operation inside one actor method.
15 Β· TUTORIAL
Continue Learning Swift Concurrency
Read What Is the Main Thread? to understand the instruction streams that the operating system schedules.
Read What Is Swift Concurrency? to see how Swift organises asynchronous work into Tasks and protects shared state through isolation.
16 Β· TUTORIAL
Download Xcode Playground
The accompanying Multiple Threads: Race Conditions Xcode playground will make the lost update visible.
It will run an unsafe shared counter repeatedly, compare the expected and actual totals, protect the same counter with a serial dispatch queue and then rebuild it as an actor.
A final experiment will separate a seat check from its reservation to demonstrate why removing a data race does not automatically remove every logical race condition.
The article explains the order of events. The playground lets you change the workload and watch that order fail.
