WWDC Quick Look 💓 By SwiftGGTeam
Eliminate data races using Swift Concurrency

Eliminate data races using Swift Concurrency

Watch original video

Highlight

Swift Concurrency eliminates data competition through three mechanisms: Task isolation, Sendable protocol, and Actor. Doug uses the “ship at sea” analogy to explain the difference between value types and reference types in concurrency, demonstrates how the compiler checks Sendable compliance at Task boundaries and Actor boundaries, and discusses advanced topics such as atomicity, main thread isolation, and strict concurrency checking modes.

Core Content

Swift 5.5 introduces async/await, Tasks, and Actors to make concurrent code easier to write. This session explains at a high level how these mechanisms work together to eliminate data races and their impact on app architecture. (00:10)

Doug used an analogy throughout the whole scene: Task is a ship on the sea, each sailing independently; Actor is an island on the sea, with its own status, and can only receive one ship at a time; Sendable is a customs inspection to ensure that things passed between ships and between ships and islands are safe. (01:17)

Value types (struct, enum) are naturally suitable for concurrent sharing, because they are copied during transfer, and each has an independent copy of data. The reference type (class) transfers a reference. If multiple tasks modify the same data at the same time, data competition will occur. Swift uses the Sendable protocol to mark which types can be safely passed across isolated domains: value types automatically conform to Sendable (as long as the members are all Sendable), and reference types can only conform if they are immutable or do their own synchronization. (02:31)

Actors are a safe way to share mutable state. All properties and methods of Actor are isolated inside Actor by default, and external access must go throughawait. Only one Task can execute on an Actor at a time, which eliminates data races. Actor itself implicitly conforms to Sendable, because referencing an Actor only gets a “map”, and actual access to the state requires passing isolation checks. (10:28)

The main thread corresponds to the Main Actor, and all UI operations must be performed on it. Main Actor is also an Actor, so it can only perform one task at a time. If it is occupied for a long time, the UI will freeze. The correct architecture is to put the view and controller in the Main Actor, and separate the business logic into other Actors or ordinary Tasks. (16:04)

Atomicity is another key topic. Actor guarantees that only one task is executed at the same time, butawaitIs the suspension point, during which the Actor can perform other tasks. If between twoawaitState assumptions are made between them, and high-level data races may occur (inconsistent states but no data corruption). The solution is to write operations that require atomicity as synchronized methods within the Actor. (19:08)

Swift 5.7 introduces strict concurrency checking mode, which is divided into three levels: Minimal (only checks where Sendable is explicitly marked), Targeted (checks code that has adopted concurrency features), and Complete (checks all code, close to Swift 6 semantics). It is recommended to enable it gradually, using@preconcurrency importTemporarily suppress warnings for external modules. (24:36)

Detailed Content

Task isolation and Sendable protocol

(01:17) Task is the basic unit of work in concurrency. Each Task has its own resources and executes independently. But tasks need to communicate with each other, which involves data sharing.

Value types are copied when passed:

enum Ripeness {
    case hard
    case perfect
    case mushy(daysPast: Int)
}

struct Pineapple {
    var weight: Double
    var ripeness: Ripeness
    
    mutating func ripen() async { ... }
    mutating func slice() -> Int { ... }
}

Key points:

  • Pineappleis a struct, copied when passed
  • Each Task gets its own copy, and modifications do not affect each other.
  • Swift compiler automatically infersPineapple: Sendable, since all members are Sendable

Reference types pass references:

final class Chicken {
    let name: String
    var currentHunger: HungerLevel
    
    func feed() { ... }
    func play() { ... }
    func produce() -> Egg { ... }
}

Key points:

  • ChickenIs a class, copy the reference when passing
  • Two Tasks refer to the same object, and simultaneous modification will cause data competition. -ChickenCannot conform to Sendable because it has mutable state

The Sendable protocol is used to mark types that can be passed safely across isolated domains:

protocol Sendable { }

struct Pineapple: Sendable { ... } // Conforms automatically
class Chicken: Sendable { } // Compiler error: unsynchronized reference type

The compiler checks Sendable at Task boundaries:

// Compiler error: Chicken is not Sendable
let petAdoption = Task {
    let chickens = await hatchNewFlock()
    return chickens.randomElement()!
}
let pet = await petAdoption.value

Key points:

  • TaskGeneric parameters ofSuccess: SendableThe return value must be Sendable
  • This constraint comes from the definition of Task:struct Task<Success: Sendable, Failure: Error>- Sendable constraints should also be used on your generic parameters if values ​​will be passed across isolated domains

Sendables of collection types propagate via conditional consistency:

// Conforms to Sendable: all members are Sendable
struct Crate: Sendable {
    var pineapples: [Pineapple]
}

// Compiler error: Chicken is not Sendable
struct Coop: Sendable {
    var flock: [Chicken]
}

For a class to be Sendable it needs to meet strict conditions:

// Compiler error: currentHunger is mutable
final class Chicken: Sendable {
    let name: String
    var currentHunger: HungerLevel
}

// A final class with only immutable members can conform to Sendable
final class Egg: Sendable {
    let color: EggColor
    let weight: Double
}

For reference types that do their own internal synchronization, you can use@unchecked Sendable, but be careful:

class ConcurrentCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
    var lock: NSLock
    var storage: [Key: Value]
}

Key points:

  • @unchecked SendableDisable compiler checks
  • Only used if correct synchronization is actually done
  • Abuse can break Swift’s data race safety guarantees

The closure on Task creation also has a Sendable check:

let lily = Chicken(name: "Lily")
Task.detached { @Sendable in
    lily.feed() // Compiler error: captures non-Sendable lily
}

Key points:

  • @SendableClosures require that all captured values are Sendable -Task.detachedThe operation parameter type is@Sendableclosure
  • This ensures that the new Task does not introduce data races

Actor isolation and state protection

(10:28) Actors provide a way to safely share mutable state:

actor Island {
    var flock: [Chicken]
    var food: [Pineapple]

    func advanceTime()
}

Key points:

  • Actor’s instance properties and instance methods are isolated on the Actor by default
  • External access must be throughawait, which is a potential hanging point
  • Only one Task can be executed on the Actor at the same time
  • Actor implicitly conforms to Sendable

Call the Actor method:

func nextRound(islands: [Island]) async {
    for island in islands {
        await island.advanceTime()
    }
}

Key points:

  • eachawait island.advanceTime()It’s all a “landing on the island”
  • All states of the Actor can be accessed while on the island
  • After leaving the island (await ends), other tasks can land on the island

Non-Sendable data cannot be shared between Tasks and Actors:

// Both are compiler errors
await myIsland.addToFlock(myChicken) // Passes a non-Sendable chicken to the Actor
myChicken = await myIsland.adoptPet() // Retrieves a non-Sendable chicken from the Actor

Actor isolated code scope:

actor Island {
    var flock: [Chicken]
    var food: [Pineapple]

    func advanceTime() {
        // The closure is not marked @Sendable, so it inherits Actor isolation
        let totalSlices = food.indices.reduce(0) { (total, nextIndex) in
            total + food[nextIndex].slice()
        }

        // Task inherits the Actor isolation from where it is created
        Task {
            flock.map(Chicken.produce)
        }

        // Task.detached does not inherit Actor isolation
        Task.detached {
            let ripePineapples = await food.filter { $0.ripeness == .perfect }
            print("There are \(ripePineapples.count) ripe pineapples on the island")
        }
    }
}

Key points:

  • Not@SendableClosures inherit Actor isolation within Actor methods -Task {}Inherit the Actor isolation context on creation -Task.detached {}Is completely independent and does not inherit any Actor isolation

usenonisolatedExplicitly break out of Actor isolation:

extension Island {
    nonisolated func meetTheFlock() async {
        let flockNames = await flock.map { $0.name }
        print("Meet our fabulous flock: \(flockNames)")
    }
}

Key points:

  • nonisolatedLet the method execute outside the Actor
  • Access to Actor state still requiresawait- Suitable for auxiliary methods that do not require frequent access to Actor state

Main Actor and App Architecture

(16:04) The Main Actor represents the main thread on which all UI operations must be performed:

@MainActor func updateView() { ... }

Task { @MainActor in
    view.selectedChicken = lily
}

nonisolated func computeAndUpdate() async {
    computeNewValues()
    await updateView()
}

Key points:

  • @MainActorCan mark functions, closures or types
  • Required when calling from a non-Main Actor contextawait- Using the Main Actor for a long time will cause the UI to freeze.

Main Actors can also mark entire types:

@MainActor
class ChickenValley: Sendable {
    var flock: [Chicken]
    var food: [Pineapple]

    func advanceTime() {
        for chicken in flock {
            chicken.eat(from: &food)
        }
    }
}

Key points:

  • All properties and methods of the type are isolated to the Main Actor by default
  • Main Actor type references are Sendable because the data is isolated
  • Suitable for views, view controllers, etc. that must run on the main thread

Impact on App architecture:

  • Views and view controllers on the Main Actor
  • Separate business logic to other Actors or ordinary Tasks
  • Task shuttles between Main Actor and other Actors on demand

Atomicity and high-level data races

(19:08) Actors eliminate low-level data races (data corruption), but high-level data races (inconsistent state) may still occur:

// Problematic: state can change between the two awaits
func deposit(pineapples: [Pineapple], onto island: Island) async {
    var food = await island.food // await 1: get a copy
    food += pineapples // Modify it locally
    await island.food = food // await 2: write it back, but it may have changed in the meantime
}

Key points:

  • twoawaitActors can perform other tasks in between
  • If other tasks are modifiedfood, the final assignment will overwrite those modifications
  • This is a high-level data race: no data corruption, but logic errors

Correct approach: write the operation as a synchronization method in the Actor:

extension Island {
    func deposit(pineapples: [Pineapple]) {
        var food = self.food
        food += pineapples
        self.food = food
    }
}

Key points:

  • Synchronous methods are executed indivisibly on the Actor
  • Noawait, no other tasks will be inserted
  • When designing Actors, make operations that require atomicity into synchronized methods

Strict concurrency check mode

(24:36) Swift 5.7 introduces three levels of strict concurrency checking:

Minimal (default):

  • Only check where Sendable is explicitly marked
  • Similar behavior to Swift 5.5/5.6
import FarmAnimals
struct Coop: Sendable {
    var flock: [Chicken] // Warning: Chicken is not Sendable
}

Targeted:

  • Check code that has adopted concurrency features (async/await, Task, Actor)
  • Remain compatible with code that does not use concurrency
@preconcurrency import FarmAnimals

func visit(coop: Coop) async {
    guard let favorite = coop.flock.randomElement() else {
        return
    }
    Task {
        favorite.play()
    }
}

Key points:

  • @preconcurrency importTemporarily suppress Sendable warnings for external modules
  • When external modules are updated, the compiler will recheck
  • Suitable for gradual migration scenarios

Complete:

  • Check all code in the module
  • Close to the expected semantics of Swift 6
import FarmAnimals

func doWork(_ body: @Sendable @escaping () -> Void) {
    DispatchQueue.global().async {
        body()
    }
}

func visit(friend: Chicken) {
    doWork {
        friend.play() // Warning: captures non-Sendable Chicken
    }
}

Key points:

  • Complete mode finds all potential data races
  • It is recommended to enable it gradually: Targeted first, then Complete
  • use@preconcurrencyHandle dependent modules that have not been updated yet

Core Takeaways

  • What to do: Review the class in the project and change the mutable state to struct or Actor.
    Why it’s worth doing: Doug used the comparison of Pineapple (struct) and Chicken (class) to illustrate that value types are naturally suitable for concurrency because they are copied when passed. Shared references to classes are the source of data races. The Swift compiler automatically infers Sendable for struct/enum, but strict conditions are required for class.
    How ​​to start: Loop through the data models in your project and ask yourself “Does this type really need reference semantics?” If not, change it to a struct. If you need to share mutable state, change it to Actor.

  • What: Enable Targeted strict concurrency checking, fix Sendable warnings.
    Why it’s worth doing: Swift 5.7’s Targeted mode only checks code that already adopts concurrency features and won’t break existing code. Fixing these warnings eliminates most potential data races. Doug recommends gradually moving towards Complete checks to prepare for Swift 6.
    How ​​to start: Change “Strict Concurrency Checking” to Targeted in Build Settings, compile the project, and fix Sendable related warnings one by one. For use with external modules@preconcurrency importTemporarily suppressed.

  • What to do: Check the asynchronous methods in the Actor and change operations that require atomicity to synchronous methods.
    Why it’s worth doing: Doug’s deposit example, twoawaitActor status may change between. Encapsulating multi-step operations into synchronized methods within Actors can ensure atomicity.
    How ​​to start: Examine the public methods of all Actors to find out if there are multipleawaitmethod. Ask yourself “If other tasks are inserted between these two awaits, will the state still be correct?” If you are not sure, change the logic to a synchronous method.

Comments

GitHub Issues · utterances