top of page

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.

bottom of page