Post

Mastering Swift Concurrency

Mastering Swift Concurrency

Intro

Swift Concurrency is a powerful framework introduced in Swift 5.5 that simplifies writing asynchronous and concurrent code, making it safer, more readable, and easier to maintain. Built around modern concepts like async/await and structured concurrency, Swift Concurrency allows developers to express asynchronous operations in a natural, linear style while ensuring thread safety and eliminating common pitfalls like callback hell or race conditions. By leveraging key features such as Task, Actor, and the cooperative scheduling model, Swift Concurrency provides developers with a robust and scalable approach to building responsive and efficient applications.

Sendable

The Sendable protocol is a cornerstone of Swift’s concurrency model, ensuring thread safety when working with data across concurrent tasks. It is a marker protocol that indicates a type can be safely transferred, or “sent,” between concurrent contexts, such as different tasks or threads. This helps mitigate data races and ensures that shared data is handled correctly in concurrent programs. Sendable itself has no requirements (e.g., no methods or properties to implement). Many types in Swift conform to Sendable automatically, including value types like Int and String, as well as immutable reference types.

However, classes do not conform to Sendable by default because they often introduce shared mutable state. If a class is thread-safe (e.g., by having no mutable state or by synchronizing access internally), you can manually conform it to Sendable. For cases where you cannot statically prove thread safety to the compiler but are confident in your implementation, you can mark the type as @unchecked Sendable. This bypasses compiler checks, but you take full responsibility for ensuring thread safety in these cases.

Task

A Task represents a unit of asynchronous work. Tasks can be canceled, and cancellation automatically propagates to child tasks. However, task cancellation in Swift is cooperative, meaning that the running code must check for cancellation and decide whether to stop execution.

Structured concurrency

Tasks can be created as either structured or unstructured. Structured concurrency ensures that tasks are tied to a specific scope, such as a function or task group, and are automatically canceled when the scope exits. This makes it easier to manage and reason about concurrent code.

Example: structured task with async let

async let allows you to create child tasks that execute concurrently. These tasks are bound to the scope of the parent and are automatically cancelled before the scope exits.

1
2
3
4
5
6
func fetchData() async throws -> (Data, Data) {
    async let firstData = fetchFromFirstSource()
    async let secondData = fetchFromSecondSource()
    
    return try await (firstData, secondData)
}

Example: TaskGroup

Use task groups to manage a dynamic number of tasks, ensuring their execution is structured.

1
2
3
4
5
6
7
8
9
func processItems() async {
    await withDiscardingTaskGroup { group in
        for item in items {
            group.addTask {
                await process(item)
            }
        }
    }
}

Unstructured concurrency

Unstructured tasks are created outside the scope of structured concurrency. While they offer more flexibility, they require more manual management of cancellation and error propagation.

1
2
3
Task {
    await performAsyncWork()
}

Unstructured tasks are not tied to a parent task, so errors and cancellations must be handled explicitly. Avoid using unstructured tasks unless absolutely necessary.

Best practices

  • Prefer structured concurrency over unstructured concurrency.
  • Use async let for fixed numbers of concurrent operations and task group for dynamic workloads.

Actor

An actor is a concurrency-safe reference type specifically designed to protect mutable state by ensuring that only one task can access an actor’s mutable state at a time. Code that interacts with an actor’s properties or methods must use await, ensuring that access is asynchronous and respects the actor’s internal synchronization.

When designing your own actor, keep its functions as simple as possible and try to minimize the time spent inside the actor. Perform non-actor-specific work outside of the actor to avoid unnecessary serialization.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
actor VideoEncoder {

    private var logs: [String] = []
    
    private func log(message: String) {
        logs.append(message)
    }
    
    nonisolated func encode(fileURL: URL) async {
        await log(message: "Start encoding video at \(fileURL)...")
        // Perform long-running operation
        await log(message: "Finished encoding video at \(fileURL)")
    }
}

Actor reentrancy

Actor reentrancy is an important concept in Swift’s concurrency model. It refers to the fact that actors are reentrant by default, meaning that they can process other tasks while waiting for an asynchronous operation to complete. This behavior allows actors to remain responsive and avoid deadlocks, but it also introduces potential challenges, such as interleaved execution and unexpected behavior when accessing an actor’s isolated state. Developers need to be mindful of these challenges when designing actor-based systems.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
actor BankAccount {
    private var balance: Int = 0

    func deposit(amount: Int) async {
        balance += amount
        print("Deposited \(amount), balance is now \(balance)")
        await Task.sleep(1_000_000_000) // Simulate a delay
        print("Finished deposit, balance is \(balance)")
    }

    func withdraw(amount: Int) async -> Bool {
        if balance >= amount {
            balance -= amount
            print("Withdrew \(amount), balance is now \(balance)")
            return true
        } else {
            print("Insufficient funds.")
            return false
        }
    }
}

let account = BankAccount()

Task {
    await account.deposit(amount: 100)
}

Task {
    let success = await account.withdraw(amount: 50)
    print("Withdrawal success: \(success)")
}
  • The deposit(amount:) method increments the balance to 100 and then suspends at the await Task.sleep line.
  • While the first task is suspended, the second task (withdraw(amount:)) executes and withdraws 50, reducing the balance to 50.
  • When the first task resumes, it prints the final balance as 50, even though it expected the balance to remain at 100.

Best practices

  • Avoid mixing long-running operations with actor-isolated state.
  • Move non-actor-specific work outside the actor by marking functions as nonisolated where appropriate.
  • Keep actors simple and focused on protecting shared mutable state.
This post is licensed under CC BY 4.0 by the author.