Skip to content
This repository was archived by the owner on Dec 31, 2025. It is now read-only.

Latest commit

 

History

History
794 lines (565 loc) · 20.9 KB

File metadata and controls

794 lines (565 loc) · 20.9 KB

The Swift Concurrency Migration Guide

Overview

Swift's concurrency system, introduced in Swift 5.5, makes asynchronous and parallel code easier to write and understand. With the Swift 6 language mode, the compiler can now guarantee that concurrent programs are free of data races.

Adopting the Swift 6 language mode is entirely under your control on a per-target basis. Targets that build with previous modes can interoperate with modules that have been migrated to the Swift 6 language mode.

Important: The Swift 6 language mode is opt-in. Existing projects will not switch to this mode without configuration changes. There is a distinction between the compiler version and language mode. The Swift 6 compiler supports four distinct language modes: "6", "5", "4.2", and "4".

Data Race Safety

Learn about the fundamental concepts Swift uses to enable data-race-free concurrent code.

Traditionally, mutable state had to be manually protected via careful runtime synchronization. Using tools such as locks and queues, the prevention of data races was entirely up to the programmer. This is notoriously difficult not just to do correctly, but also to keep correct over time.

More formally, a data race occurs when one thread accesses memory while the same memory is being mutated by another thread. The Swift 6 language mode eliminates these problems by preventing data races at compile time.

Data Isolation

Swift's concurrency system allows the compiler to understand and verify the safety of all mutable state. It does this with a mechanism called data isolation. Data isolation guarantees mutually exclusive access to mutable state.

Isolation Domains

Data isolation is the mechanism used to protect shared mutable state. An isolation domain is an independent unit of isolation.

All function and variable declarations have a well-defined static isolation domain:

  1. Non-isolated
  2. Isolated to an actor value
  3. Isolated to a global actor

Non-isolated

Functions and variables do not have to be a part of an explicit isolation domain. In fact, a lack of isolation is the default, called non-isolated.

func sailTheSea() {
}

This top-level function has no static isolation, making it non-isolated.

class Chicken {
    let name: String
    var currentHunger: HungerLevel
}

This is an example of a non-isolated type.

Actors

Actors give the programmer a way to define an isolation domain, along with methods that operate within that domain. All stored properties of an actor are isolated to the enclosing actor instance.

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

    func addToFlock() {
        flock.append(Chicken())
    }
}

Here, every Island instance will define a new domain, which will be used to protect access to its properties. The method Island.addToFlock is said to be isolated to self.

Actor isolation can be selectively disabled:

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

    nonisolated func canGrow() -> PlantSpecies {
        // neither flock nor food are accessible here
    }
}

Global Actors

Global actors share all of the properties of regular actors, but also provide a means of statically assigning declarations to their isolation domain.

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

This class is statically-isolated to MainActor.

Tasks

A task is a unit of work that can run concurrently within your program. Tasks may run concurrently with one another, but each individual task only executes one function at a time.

Task {
    flock.map(Chicken.produce)
}

A task always has an isolation domain. They can be isolated to an actor instance, a global actor, or could be non-isolated.

Isolation Inference and Inheritance

There are many ways to specify isolation explicitly. But there are cases where the context of a declaration establishes isolation implicitly, via isolation inference.

Classes

A subclass will always have the same isolation as its parent.

@MainActor
class Animal {
}

class Chicken: Animal {
}

Because Chicken inherits from Animal, the static isolation of the Animal type also implicitly applies.

The static isolation of a type will also be inferred for its properties and methods by default.

Protocols

A protocol conformance can implicitly affect isolation. However, the protocol's effect on isolation depends on how the conformance is applied.

@MainActor
protocol Feedable {
    func eat(food: Pineapple)
}

// inferred isolation applies to the entire type
class Chicken: Feedable {
}

// inferred isolation only applies within the extension
extension Pirate: Feedable {
}

Isolation Boundaries

Moving values into or out of an isolation domain is known as crossing an isolation boundary. Values are only ever permitted to cross an isolation boundary where there is no potential for concurrent access to shared mutable state.

Sendable Types

In some cases, all values of a particular type are safe to pass across isolation boundaries because thread-safety is a property of the type itself. This is represented by the Sendable protocol.

Swift encourages using value types because they are naturally safe. Value types in Swift are implicitly Sendable when all their stored properties are also Sendable. However, this implicit conformance is not visible outside of their defining module.

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

struct Pineapple {
    var weight: Double
    var ripeness: Ripeness
}

Here, both types are implicitly Sendable since they are composed entirely of Sendable value types.

Actor-Isolated Types

Actors are not value types, but because they protect all of their state in their own isolation domain, they are inherently safe to pass across boundaries. This makes all actor types implicitly Sendable.

Global-actor-isolated types are also implicitly Sendable for similar reasons.

Reference Types

Unlike value types, reference types cannot be implicitly Sendable. To make a class Sendable it must contain no mutable state and all immutable properties must also be Sendable. Further, the compiler can only validate the implementation of final classes.

final class Chicken: Sendable {
    let name: String
}

Suspension Points

A task can switch between isolation domains when a function in one domain calls a function in another. A call that crosses an isolation boundary must be made asynchronously.

@MainActor
func stockUp() {
    // beginning execution on MainActor
    let food = Pineapple()

    // switching to the island actor's domain
    await island.store(food)
}

Potential suspension points are marked in source code with the await keyword.

Atomicity

While actors do guarantee safety from data races, they do not ensure atomicity across suspension points. Because the current isolation domain is freed up to perform other work, actor-isolated state may change after an asynchronous call.

func deposit(pineapples: [Pineapple], onto island: Island) async {
   var food = await island.food
   food += pineapples
   await island.store(food)
}

This code assumes, incorrectly, that the island actor's food value will not change between asynchronous calls. Critical sections should always be structured to run synchronously.

Common Compiler Errors

Identify, understand, and address common problems you can encounter while working with Swift concurrency.

After enabling complete checking, many projects can contain a large number of warnings and errors. Don't get overwhelmed! Most of these can be tracked down to a much smaller set of root causes.

Unsafe Global and Static Variables

Global state, including static variables, are accessible from anywhere in a program. This visibility makes them particularly susceptible to concurrent access.

Sendable Types

var supportedStyleCount = 42

Here, we have defined a global variable that is both non-isolated and mutable from any isolation domain.

Two functions with different isolation domains accessing this variable risks a data race:

@MainActor
func printSupportedStyles() {
    print("Supported styles: ", supportedStyleCount)
}

func addNewStyle() {
    let style = Style()
    supportedStyleCount += 1
    storeStyle(style)
}

One way to address the problem is by changing the variable's isolation:

@MainActor
var supportedStyleCount = 42

If the variable is meant to be constant:

let supportedStyleCount = 42

If there is synchronization in place that protects this variable:

/// This value is only ever accessed while holding `styleLock`.
nonisolated(unsafe) var supportedStyleCount = 42

Only use nonisolated(unsafe) when you are carefully guarding all access to the variable with an external synchronization mechanism.

Non-Sendable Types

Global reference types present an additional challenge, because they are typically not Sendable.

class WindowStyler {
    var background: ColorComponents

    static let defaultStyler = WindowStyler()
}

The issue is WindowStyler is a non-Sendable type, making its internal state unsafe to share across isolation domains.

One option is to isolate the variable to a single domain using a global actor. Alternatively, it might make sense to add a conformance to Sendable directly.

Protocol Conformance Isolation Mismatch

A protocol defines requirements that a conforming type must satisfy, including static isolation. This can result in isolation mismatches between a protocol's declaration and conforming types.

Under-Specified Protocol

protocol Styler {
    func applyStyle()
}

@MainActor
class WindowStyler: Styler {
    func applyStyle() {
        // access main-actor-isolated state
    }
}

It is possible that the protocol actually should be isolated, but has not yet been updated for concurrency.

Adding Isolation

If protocol requirements are always called from the main actor, adding @MainActor is the best solution:

// entire protocol
@MainActor
protocol Styler {
    func applyStyle()
}

// per-requirement
protocol Styler {
    @MainActor
    func applyStyle()
}

Asynchronous Requirements

For methods that implement synchronous protocol requirements the isolation of implementations must match exactly. Making a requirement asynchronous offers more flexibility:

protocol Styler {
    func applyStyle() async
}

@MainActor
class WindowStyler: Styler {
    // matches, even though it is synchronous and actor-isolated
    func applyStyle() {
    }
}

Preconcurrency Conformance

Annotating a protocol conformance with @preconcurrency makes it possible to suppress errors about any isolation mismatches:

@MainActor
class WindowStyler: @preconcurrency Styler {
    func applyStyle() {
        // implementation body
    }
}

Isolated Conforming Type

Sometimes the protocol's static isolation is appropriate, and the issue is only caused by the conforming type.

Non-Isolated

@MainActor
class WindowStyler: Styler {
    nonisolated func applyStyle() {
        // perhaps this implementation doesn't involve
        // other MainActor-isolated state
    }
}

Crossing Isolation Boundaries

The compiler will only permit a value to move from one isolation domain to another when it can prove it will not introduce data races.

Implicitly-Sendable Types

Many value types consist entirely of Sendable properties. The compiler will treat types like this as implicitly Sendable, but only when they are non-public.

public struct ColorComponents {
    public let red: Float
    public let green: Float
    public let blue: Float
}

@MainActor
func applyBackground(_ color: ColorComponents) {
}

func updateStyle(backgroundColor: ColorComponents) async {
    await applyBackground(backgroundColor)
}

Because ColorComponents is marked public, it will not implicitly conform to Sendable.

A straightforward solution is to make the type's Sendable conformance explicit:

public struct ColorComponents: Sendable {
    // ...
}

Preconcurrency Import

Even if the type in another module is actually Sendable, it is not always possible to modify its definition. Use a @preconcurrency import to downgrade diagnostics:

// ColorComponents defined here
@preconcurrency import UnmigratedModule

func updateStyle(backgroundColor: ColorComponents) async {
    // crossing an isolation domain here
    await applyBackground(backgroundColor)
}

Latent Isolation

Sometimes the apparent need for a Sendable type can actually be the symptom of a more fundamental isolation problem.

@MainActor
func applyBackground(_ color: ColorComponents) {
}

func updateStyle(backgroundColor: ColorComponents) async {
    await applyBackground(backgroundColor)
}

Since updateStyle(backgroundColor:) is working directly with MainActor-isolated functions and non-Sendable types, just applying MainActor isolation may be more appropriate:

@MainActor
func updateStyle(backgroundColor: ColorComponents) async {
    applyBackground(backgroundColor)
}

Sending Argument

The compiler will permit non-Sendable values to cross an isolation boundary if the compiler can prove it can be done safely:

func updateStyle(backgroundColor: sending ColorComponents) async {
    // this boundary crossing can now be proven safe in all cases
    await applyBackground(backgroundColor)
}

Sendable Conformance

When encountering problems related to crossing isolation domains, you can make a type Sendable in four ways:

Global Isolation

@MainActor
public struct ColorComponents {
    // ...
}

Actors

actor Style {
    private var background: ColorComponents
}

Manual Synchronization

class Style: @unchecked Sendable {
    private var background: ColorComponents
    private let queue: DispatchQueue
}

Sendable Reference Types

To allow a checked Sendable conformance, a class:

  • Must be final
  • Cannot inherit from another class other than NSObject
  • Cannot have any non-isolated mutable properties
final class Style: Sendable {
    private let background: ColorComponents
}

Non-Isolated Initialization

Actor-isolated types can present a problem when they are initialized in a non-isolated context:

@MainActor
class WindowStyler {
    nonisolated init(name: String) {
        self.primaryStyleName = name
    }
}

Non-Isolated Deinitialization

Even if a type has actor isolation, deinitializers are always non-isolated:

actor BackgroundStyler {
    private let store = StyleStore()

    deinit {
        Task { [store] in
            await store.stopNotifications()
        }
    }
}

Important: Never extend the life-time of self from within deinit.

Migration Strategy

Get started migrating your project to the Swift 6 language mode.

When faced with a large number of problems, don't panic. Frequently, you'll find yourself making substantial progress with just a few changes.

Strategy

The approach has three key steps:

  • Select a module
  • Enable stricter checking with Swift 5
  • Address warnings

This process will be inherently iterative.

Begin from the Outside

It can be easier to start with the outer-most root module in a project. Changes here can only have local effects, making it possible to keep work contained.

Use the Swift 5 Language Mode

It is possible to incrementally enable more of the Swift 6 checking mechanisms while remaining in Swift 5 mode. This will surface issues only as warnings.

To start, enable a single upcoming concurrency feature:

Proposal Description Feature Flag
SE-0401 Remove Actor Isolation Inference caused by Property Wrappers DisableOutwardActorInference
SE-0412 Strict concurrency for global variables GlobalConcurrency
SE-0418 Inferring Sendable for methods and key path literals InferSendableFromCaptures

After you have addressed issues uncovered by upcoming feature flags, enable complete checking for the module.

Address Warnings

There is one guiding principle: express what is true now. Resist the urge to refactor your code to address issues.

Enabling Complete Concurrency Checking

Incrementally address data-race safety issues by enabling diagnostics as warnings in your project.

Using the Swift compiler

~ swift -strict-concurrency=complete main.swift

Using SwiftPM

Command-line invocation

~ swift build -Xswiftc -strict-concurrency=complete
~ swift test -Xswiftc -strict-concurrency=complete

Package manifest

With Swift 5.9 or Swift 5.10 tools:

.target(
  name: "MyTarget",
  swiftSettings: [
    .enableExperimentalFeature("StrictConcurrency")
  ]
)

When using Swift 6.0 tools or later:

.target(
  name: "MyTarget",
  swiftSettings: [
    .enableUpcomingFeature("StrictConcurrency")
  ]
)

Using Xcode

Set the "Strict Concurrency Checking" setting to "Complete" in the Xcode build settings.

Enabling The Swift 6 Language Mode

Guarantee your code is free of data races by enabling the Swift 6 language mode.

Using the Swift compiler

~ swift -swift-version 6 main.swift

Using SwiftPM

Package manifest

A Package.swift file that uses swift-tools-version of 6.0 will enable the Swift 6 language mode for all targets:

// swift-tools-version: 6.0

let package = Package(
    name: "MyPackage",
    targets: [
        .target(name: "FullyMigrated"),
        .target(
            name: "NotQuiteReadyYet",
            swiftSettings: [
                .swiftLanguageMode(.v5)
            ]
        )
    ]
)

Using Xcode

Set the "Swift Language Version" setting to "6" in the Xcode build settings.

Incremental Adoption

Learn how you can introduce Swift concurrency features into your project incrementally.

Wrapping Callback-Based Functions

APIs that accept and invoke a single function on completion are an extremely common pattern in Swift. You can wrap this function up into an asynchronous version using continuations:

func updateStyle(backgroundColor: ColorComponents) async {
    await withCheckedContinuation { continuation in
        updateStyle(backgroundColor: backgroundColor) {
            continuation.resume()
        }
    }
}

Note: You have to take care to resume the continuation exactly once.

Dynamic Isolation

Dynamic isolation provides runtime mechanisms you can use as a fallback for describing data isolation. It can be an essential tool for interfacing a Swift 6 component with another that has not yet been updated.

Preconcurrency

You can stage in diagnostics caused by adding global actor isolation on a protocol using @preconcurrency:

@preconcurrency @MainActor
protocol Styler {
    func applyStyle()
}

Assume Isolated

When you know code is running on a specific actor but the compiler cannot verify this statically:

func doSomething() {
    MainActor.assumeIsolated {
        // Code that requires MainActor
    }
}

Runtime Behavior

Learn how Swift concurrency runtime semantics differ from other runtimes.

Limiting concurrency using Task Groups

When dealing with a large list of work, avoid creating thousands of tasks at once:

let lotsOfWork: [Work] = ... 
let maxConcurrentWorkTasks = min(lotsOfWork.count, 10)

await withTaskGroup(of: Something.self) { group in
    var submittedWork = 0
    for _ in 0..<maxConcurrentWorkTasks {
        group.addTask {
            await lotsOfWork[submittedWork].work() 
        }
        submittedWork += 1
    }
    
    for await result in group {
        process(result)
    
        if submittedWork < lotsOfWork.count, 
           let remainingWorkItem = lotsOfWork[submittedWork] {
            group.addTask {
                await remainingWorkItem.work() 
            }  
            submittedWork += 1
        }
    }
}

Source Compatibility

Swift 6 includes a number of evolution proposals that could potentially affect source compatibility. These are all opt-in for the Swift 5 language mode.

Key Changes

  • NonfrozenEnumExhaustivity: Lack of a required @unknown default has changed from a warning to an error
  • StrictConcurrency: Will introduce errors for any code that risks data races
  • DeprecateApplicationMain: Will introduce an error for any code that has not migrated to using @main

For a complete list of source compatibility changes, consult the Swift Evolution proposals.