Skip to content

Step 1 of 6 · Reading · ~4 min

Learn

Concurrency

Swift 5.5 added native async/await and structured concurrency. The model is similar to JavaScript and C#, but Swift's takes type-safety and cancellation seriously.

Basic async/await

func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

// Calling — must be in an async context:
let user = try await fetchUser(id: 42)

async marks a function as suspending — it can pause without blocking a thread. await is required at every call site to make suspension visible.

Tasks — top-level async work

Most code starts an async chain via a Task:

let task = Task {
    let user = try await fetchUser(id: 42)
    print(user.name)
}

// Cancel from outside:
task.cancel()

Parallel execution with async let

func fetchProfile(id: Int) async throws -> (User, [Post]) {
    async let user = fetchUser(id: id)
    async let posts = fetchPosts(userId: id)
    return try await (user, posts)        // both run in parallel
}

async let kicks off the work immediately. The await later collects the result. Total time = max(individual times), not sum.

TaskGroup — N parallel tasks

func fetchAll(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)
            }
        }
        var users: [User] = []
        for try await user in group {
            users.append(user)
        }
        return users
    }
}

Dynamic number of parallel tasks. Cancellation propagates: if you throw, all sibling tasks are cancelled.

Cancellation

Cancellation in Swift is cooperative — your code must check:

func longComputation() async throws -> Int {
    var result = 0
    for i in 0..<1_000_000 {
        try Task.checkCancellation()    // throws CancellationError if cancelled
        result += i
    }
    return result
}

Most standard library async APIs check cancellation automatically. Long-running custom loops should call Task.checkCancellation periodically.

Actors — thread-safe shared state

Swift 5.5 also added actors for protecting shared state:

actor BankAccount {
    private var balance: Int = 0
    
    func deposit(_ amount: Int) {
        balance += amount
    }
    
    func getBalance() -> Int {
        return balance
    }
}

let account = BankAccount()
await account.deposit(100)            // await — entering the actor
let b = await account.getBalance()    // serialized access

Actors serialize access to their state — only one task can be inside an actor's methods at a time. Race conditions on actor state become impossible.

Common mistakes

  • Calling async from sync without a Task — compile error. Wrap in Task { ... }.
  • Forgetting try for throwing async callstry await is the common combo.
  • Awaiting in a loop when independent items could run in parallel — use async let or TaskGroup.
  • Nested Task { } in actor methods unintentionally — the inner Task is detached from the actor's isolation. Use await instead when you want serialized access.

What our grader can actually run

Our Swift grader is Swift 5.2, which predates structured concurrency. async, await, actor and Task are not keywords there, so a file using them does not compile - the parser reads async as a stray identifier and reports consecutive statements on a line must be separated by ';'. Everything above is the code you will write in a current project; it simply cannot be executed on this grader.

So the exercise below reaches for the tool the same runtime already had, Dispatch:

import Foundation
import Dispatch

let inputs = [5, 10, 15]
var results = [Int](repeating: 0, count: inputs.count)
let lock = NSLock()

DispatchQueue.concurrentPerform(iterations: inputs.count) { i in
    let doubled = inputs[i] * 2
    lock.lock()
    results[i] = doubled
    lock.unlock()
}
print(results.map(String.init).joined(separator: " "))

// 10 20 30

Worth lining the two models up while they are side by side:

async let and task groupsDispatchQueue.concurrentPerform
a waiting task suspends and holds no threada waiting task blocks and holds its thread
results arrive as typed bindings you awaityou write into shared storage yourself
isolation is checked by the compilerNSLock is your responsibility, and omitting it is a data race

That last row is the reason the newer model exists. concurrentPerform hands you the parallelism and nothing else: two closures can reach the same array at the same moment, so indexing by i and taking the lock around the write is what makes the result correct rather than merely usually correct. Take the lock around the whole closure instead and you have re-serialised the work you just parallelised.

Up nextCodable: JSON Encoding and DecodingConcurrency

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…