Advanced Swift Concurrency: TaskGroup, AsyncStream and Sendable

Advanced iOS Development: Concurrency, Architecture, Security and Release · Advanced Swift · 7 min read · Updated

A task group

  1. The parent opens a task group and adds one child task for each item.
  2. The child tasks run at the same time instead of one after another.
  3. Results come back in the order the tasks finish, so key each one by an id.
  4. If one child throws, the group is cancelled and the parent gets the error.

The beginner course taught async, await, Task and actors. In real apps you also need to run a *variable* number of jobs at once, stop work that nobody wants any more, turn callbacks and events into a sequence you can loop over, and satisfy the compiler's checks for data races. These are the topics here, and they are all common in senior interviews.

TaskGroup: a variable number of tasks

async let is fine when you know how many calls there are. When you have a list, such as one request per account, use a task group. You add one child task per item. Child tasks run at the same time, and you collect results as they finish.

func fetchTotal(_ id: Int) async throws -> Double {
    try await Task.sleep(for: .milliseconds(100))
    return Double(id) * 10
}

func totals(for ids: [Int]) async throws -> [Int: Double] {
    try await withThrowingTaskGroup(of: (Int, Double).self) { group in
        for id in ids {
            group.addTask { (id, try await fetchTotal(id)) }
        }
        var result: [Int: Double] = [:]
        for try await (id, total) in group {
            result[id] = total
        }
        return result
    }
}
  • Results arrive in the order that tasks finish, and not the order you added them, so key them by an id, as above.
  • If one child throws, the group is cancelled and the error comes out of the for try await loop.
  • Do not start thousands of network calls at the same moment. Add a limit by starting a few tasks first and adding the next one each time one finishes.

Cancellation is cooperative

Cancelling a task does not stop it by force. It sets a flag, and the task must look at the flag and stop itself. Most Apple async APIs, such as Task.sleep and URLSession, check the flag for you and throw CancellationError. In your own long loops, check on purpose.

func processAll(_ items: [Int]) async throws -> Int {
    var done = 0
    for item in items {
        try Task.checkCancellation()   // throws if the task was cancelled
        _ = item * 2                   // pretend this is real work
        done += 1
    }
    return done
}

let task = Task { try await processAll(Array(0..<1_000_000)) }
task.cancel()

A very common real use is search as you type. Each key press cancels the previous search task and starts a new one after a short pause, so only the last word is searched.

struct SearchView: View {
    @State private var query = ""
    @State private var results: [String] = []
    @State private var searchTask: Task<Void, Never>?

    var body: some View {
        List(results, id: \.self) { Text($0) }
            .searchable(text: $query)
            .onChange(of: query) { _, newValue in
                searchTask?.cancel()
                searchTask = Task {
                    try? await Task.sleep(for: .milliseconds(300))
                    guard !Task.isCancelled else { return }
                    results = await search(newValue)
                }
            }
    }

    func search(_ text: String) async -> [String] {
        ["\(text) one", "\(text) two"]   // replace with a real call
    }
}

AsyncStream: events as a sequence

An AsyncSequence is like an array whose items arrive over time. You loop over it with for await. AsyncStream lets you make one from your own source, such as a timer, a location update or an old delegate callback. You yield values into it, and finish when there are no more.

func ticks(every seconds: Double) -> AsyncStream<Int> {
    AsyncStream { continuation in
        let task = Task {
            var count = 0
            while !Task.isCancelled {
                continuation.yield(count)
                count += 1
                try? await Task.sleep(for: .seconds(seconds))
            }
            continuation.finish()
        }
        continuation.onTermination = { _ in task.cancel() }
    }
}

// somewhere async:
for await tick in ticks(every: 1) {
    print("tick", tick)
    if tick == 3 { break }    // leaving the loop ends the stream
}

The onTermination closure cleans up when the reader stops, so the timer task does not run forever. The same idea turns a callback-based API, such as a location manager delegate, into a clean stream.

Sendable and strict concurrency

A type is Sendable when it is safe to pass between tasks, because nothing can be changed by two places at once. Value types made of Sendable parts, such as simple structs and enums, are Sendable. Actors are Sendable. A class is only Sendable if it is final and its stored properties are immutable (or protected). The compiler uses this to find data races *before* the app runs.

struct Receipt: Sendable {      // a struct of simple values
    let id: UUID
    let total: Double
}

final class AppConfig: Sendable {   // final class, immutable properties
    let baseURL: URL
    init(baseURL: URL) { self.baseURL = baseURL }
}

Actors: two more things to know

  • Reentrancy: when an actor method hits an await, other calls can run on the same actor before it continues. So state read before the await may have changed after it. Check again after the await when it matters.
  • Global actors: @MainActor is the one you know. You can make your own, but you rarely need to.
  • `nonisolated`: marks an actor member that does not touch the actor's data, so it can be called without await.

Common mistakes

  • Starting a Task and forgetting to keep a reference, so it cannot be cancelled later.
  • Long loops that never check for cancellation.
  • Reading actor state before an await and trusting it after.
  • Using @unchecked Sendable to make a warning go away.

Your task

  1. Write totals(for:) and call it with ten ids. Print the dictionary.
  2. Make the search-as-you-type view, replace search with a function that waits for 200 ms, and watch earlier searches get cancelled.
  3. Write ticks(every:) and read it with for await. Break after five ticks and confirm the timer task stops.
  4. Make a struct and a final class that are Sendable, and write what would break if the class had a var property.

How to check it worked

  • The group returns all ids even though tasks finish in a different order.
  • Typing quickly in the search box runs only the last search to the end.
  • You can explain what cooperative cancellation means and name one API that checks for it for you.

Frequently asked questions

What is the difference between async let and a TaskGroup?

async let suits a fixed, small number of calls you can write out. A task group suits a list of any length, where you add one child task for each item and gather the results.

Does cancelling a Swift task stop it immediately?

No. Cancellation sets a flag and the task must check it. Many Apple APIs check for you and throw CancellationError. In your own loops, call Task.checkCancellation().

What does Sendable mean?

It marks a type that is safe to pass between tasks without data races. Simple value types and actors are Sendable, and classes must be final with immutable properties.