Skip to content

Swift concurrency

Hub › iOS › Swift › Swift concurrency

Goal

You will write async functions, create tasks and task groups, define actors to protect state, use @MainActor for UI updates, and mark types Sendable for safe cross-concurrency passing. After this page you can write correct concurrent code in Swift — avoiding data races and thread explosion — without dropping down to raw GCD or OSLock.

Prerequisites

The problem concurrency solves

Before Swift concurrency (iOS 13+), concurrent code used Grand Central Dispatch (DispatchQueue.main.async, DispatchQueue.global().async) or OperationQueue. These patterns are manual, error-prone, and encourage thread explosion:

swift
// GCD callback-hell pattern
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
    DispatchQueue.global().async {
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let task = URLSession.shared.dataTask(with: url) { data, _, error in
            if let error = error {
                DispatchQueue.main.async { completion(.failure(error)) }
                return
            }
            guard let data = data else {
                DispatchQueue.main.async { completion(.failure(URLError(.badServerResponse))) }
                return
            }
            do {
                let user = try JSONDecoder().decode(User.self, from: data)
                DispatchQueue.main.async { completion(.success(user)) }
            } catch {
                DispatchQueue.main.async { completion(.failure(error)) }
            }
        }
        task.resume()
    }
}

The problems: every callback must manually hop threads, errors are easily lost, and a missing completion call produces a silent hang. Swift concurrency replaces this with a linear, suspendible model enforced by the compiler.

async/await — the model

A function marked async can suspend itself at await points, releasing its thread until the awaited work completes. The function does not block — it yields control cooperatively, and the runtime reschedules it when the result is ready:

swift
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)
}

// Call site — synchronous-looking, but suspension happens at await
let user = try await fetchUser(id: 42)

Every await is a suspension point. The compiler inserts them explicitly — you can see exactly where the function might pause. Inside an async function you can call other async functions with await:

swift
func loadProfile(for id: Int) async throws -> Profile {
    let user = try await fetchUser(id: id)
    let posts = try await fetchPosts(for: id)
    return Profile(user: user, posts: posts)
}

The runtime desugars each async function into a state machine (a re-entrant closure with an internal enum tracking which await to resume at). This is similar to how C# async methods or JavaScript async functions work, but Swift's is stackless — the suspended state is a heap-allocated continuation, not a retained call stack.

Contrast with GCD

async/awaitGCD
Thread usageCooperative — same thread re-used across await pointsThread-per-block — can create hundreds of threads
SuspensionCompiler-inserted state machineManual async block boundaries
CancellationBuilt-in (task-level)Manual (DispatchWorkItem.cancel())
PriorityTask priority propagates automaticallyManual qos assignment

GCD creates a new block (and potentially a new thread) for every unit of work. Swift concurrency reuses threads within a cooperative pool — the runtime maintains a small number of threads (roughly CPU count) and suspends/resumes tasks on them. This prevents thread explosion, the leading cause of iOS crashes from GCD-heavy code.

Tasks and structured concurrency

A task is a unit of asynchronous work. Every async function runs inside a task. Tasks form a tree: a parent task creates child tasks, and the parent waits for all children before it completes.

Task { } — creating a child task

Task { } creates a child task in the current context. Use it to call async code from a synchronous context (like a SwiftUI button handler):

swift
func handleTap() {
    Task {                         // creates a new child task
        let user = try await fetchUser(id: 42)
        await updateUI(user)
    }
    // handleTap returns immediately — the task runs concurrently
}

The task inherits its priority and actuator from the current context. If called on the main thread, the task's body also runs on @MainActor by default.

Task.detached { } — unstructured task

A detached task has no parent, inherits no priority, and is not automatically cancelled:

swift
Task.detached {
    let user = try await fetchUser(id: 42)
    await MainActor.run {
        updateUI(user)
    }
}

Use Task.detached only when you need a fire-and-forget operation that outlives the current scope — for example, background analytics upload that must continue even after the view that started it disappears. In most code, prefer Task { }.

Structured concurrency

The rule: every task must have a parent. A parent task suspends at the end of its scope until all child tasks complete. This is called structured concurrency — the task tree mirrors the code's lexical structure:

swift
func loadData() async {
    // Parent task scope
    async let user = fetchUser(id: 42)       // child task 1
    async let posts = fetchPosts(for: 42)    // child task 2

    // Both children must complete before loadData returns
    let (loadedUser, loadedPosts) = await (user, posts)
    // use loadedUser, loadedPosts
}

async let creates a child task whose result is awaited at the next await point. If loadData() is cancelled, both children are automatically cancelled too.

TaskGroup — dynamic child tasks

When the number of child tasks is not known at compile time (e.g., a collection), use withTaskGroup:

swift
func fetchAllUsers(ids: [Int]) async throws -> [User] {
    try await withTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)
            }
        }

        var results: [User] = []
        for try await user in group {
            results.append(user)
        }
        return results
    }
}

Each group.addTask spawns a child task. The for try await user in group loop collects results as they finish — possibly out of order — and the group as a whole completes only when all children have finished.

withThrowingTaskGroup is the throwing variant (child tasks can throw). withDiscardingTaskGroup (Swift 5.9+) avoids accumulating results when you don't need them — useful for streaming or fire-and-forget child tasks.

Returning values from a task

Task { } returns a Task<T, Error> — call .value to await the result:

swift
let task = Task { () -> User in
    try await fetchUser(id: 42)
}
let user = try await task.value   // waits for the task to complete

The Task itself is an Identifiable handle — you can store it, cancel it later, or check its isCancelled property.

Cancellation

Every task carries a cancellation flag. The flag is set automatically when a parent task is cancelled (structured concurrency propagation). Check it explicitly:

swift
func process() async {
    guard !Task.isCancelled else { return }

    for item in items {
        try Task.checkCancellation()   // throws CancellationError if cancelled
        await process(item)
    }
}
  • Task.isCancelled — check without throwing.
  • Task.checkCancellation() — throws CancellationError if cancelled, making it easy to exit early from a throwing function.
  • Task.withTaskCancellationHandler(operation:onCancel:) — attach a side effect (e.g., close a file handle) when cancellation occurs.

Actors

An actor protects its mutable state by serializing access. All access to an actor's stored properties goes through a single execution queue — the actor's executor. This is the concurrency-safe replacement for a mutex or serial dispatch queue:

swift
actor Counter {
    var count = 0

    func increment() {
        count += 1                     // safe — we are inside the actor
    }

    func getCount() -> Int {
        count
    }
}

let counter = Counter()
Task {
    await counter.increment()          // await required — crossing actor boundary
    let value = await counter.getCount()
    print(value)
}

Every call from outside the actor requires await. Inside the actor, the properties are directly accessible — the compiler knows the code is running on the actor's executor.

The await requirement

When you call counter.increment() from outside, the calling task must suspend if the actor is currently busy on another call. The await marks this potential suspension point:

swift
Task {
    await counter.increment()   // may suspend if another task is inside the actor
}

If two tasks call counter.increment() concurrently, the actor serializes them: one runs immediately, the other suspends and runs after the first completes.

Non-reentrancy

Actors are non-reentrant: if an actor method calls await (suspends), the actor does not process new incoming calls until the current call completes. This prevents a class of reentrancy bugs that mutex-based code is vulnerable to:

swift
actor BankAccount {
    var balance = 100

    func transfer(amount: Int, to other: BankAccount) async {
        balance -= amount
        // Suspension point — no other call can touch balance
        await other.deposit(amount: amount)
        // balance is still correct here because no other call interleaved
    }

    func deposit(amount: Int) {
        balance += amount
    }
}

Non-reentrancy is a safety guarantee: while an actor suspends at an await, its state is frozen — no other code can observe partial updates.

nonisolated and nonisolated(unsafe)

A method that doesn't access actor state can be marked nonisolated, removing the await requirement at the call site:

swift
actor Logger {
    var logFile: URL?

    nonisolated func format(_ message: String) -> String {
        "[LOG] \(message)"                       // no access to actor state
    }

    func write(_ message: String) {
        logFile = URL(fileURLWithPath: "/tmp/log.txt")   // needs isolation
    }
}

let logger = Logger()
let formatted = logger.format("hello")            // no await — nonisolated
await logger.write(formatted)                     // await — crossing actor boundary

nonisolated(unsafe) skips the compiler check entirely — use it only when you manually guarantee thread safety (e.g., a lock inside the method).

@MainActor

@MainActor is a global actor that executes all its code on the main dispatch queue. It replaces DispatchQueue.main.async with a compiler-checked annotation:

swift
@MainActor
class ViewModel: ObservableObject {
    @Published var name = ""

    func updateUI() {
        // This runs on the main thread — guaranteed by @MainActor
        name = "Alice"
    }
}

// Calling from a non-main-actor context:
Task {
    await viewModel.updateUI()   // await required — crossing to MainActor
}

@MainActor can be applied at multiple levels:

swift
// Type-level — every method and property is on MainActor
@MainActor
class UserViewModel {
    var name: String = ""
}

// Function-level — just this function
@MainActor
func updateUI() { }

// Property-level — just this property
@MainActor var cachedName: String = ""

// Closure-level
Task { @MainActor in
    // runs on main thread
}

The global MainActor.shared is a singleton MainActor instance. You can also call await MainActor.run { … } to hop to the main actor from a non-actor context:

swift
await MainActor.run {
    self.label.text = "Updated"
}

The compiler enforces that main-actor-isolated code is only called from the main thread. If you call it from a nonisolated context, you need await — exactly like any actor boundary.

Why it replaces DispatchQueue.main.async

Before Swift concurrency, every UIKit update required a manual hop:

swift
DispatchQueue.main.async {
    self.label.text = "Updated"
}

The problem: nothing forced you to do this. You could update UI from a background thread and get silent corruption (or a crash in debug builds). @MainActor makes the hop a compile-time requirement — the function signature declares "I must run on the main thread," and the compiler checks every call site.

Sendable and @Sendable

Sendable is a marker protocol — no requirements — that declares a type is safe to pass across concurrency boundaries:

swift
struct User: Sendable {
    let id: Int
    let name: String
}

// Value types are implicitly Sendable
// Classes need explicit opt-in

Automatic Sendable conformance

Value types whose stored properties are all Sendable are automatically Sendable:

swift
struct Point: Sendable {       // implicit — stored properties (Double, Double) are Sendable
    let x: Double
    let y: Double
}

enum Result<T: Sendable>: Sendable {  // enum with associated values that are Sendable
    case success(T)
    case failure(Error)               // Error is not Sendable! This is a problem.
}

Error is not Sendable. Passing an error across concurrency boundaries requires wrapping it in a Sendable-conforming type or using any Error & Sendable (Swift 5.7+).

Classes and Sendable

A class must be either:

  1. final with immutable stored properties — all let properties that are Sendable:
swift
final class ImmutableConfig: Sendable {
    let timeout: Int       // Int is Sendable
    let url: URL           // URL is Sendable
    init(timeout: Int, url: URL) {
        self.timeout = timeout
        self.url = url
    }
}
  1. @unchecked Sendable — you promise it's safe, the compiler trusts you:
swift
final class LockedCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Any] = [:]

    func get(_ key: String) -> Any? {
        lock.withLock { storage[key] }
    }

    func set(_ key: String, _ value: Any) {
        lock.withLock { storage[key] = value }
    }
}

Use @unchecked Sendable sparingly — it disables compiler verification. Only use it when you have manual synchronization (lock, queue, atomic) that the compiler cannot reason about.

@Sendable closures

A closure marked @Sendable must not capture mutable shared state:

swift
func fetch(completion: @Sendable @escaping (Result<User, Error>) -> Void) {
    Task {
        let result = await Result { try await fetchUser(id: 42) }
        completion(result)            // safe — no mutable captures
    }
}

// This is rejected:
var counter = 0
fetch { result in
    counter += 1         // error: mutation of captured var 'counter' from @Sendable closure
}

The compiler checks that a @Sendable closure does not capture mutable variables, nonsendable types, or create potential data races. This is Swift's compile-time answer to "did you forget to synchronize?"

AsyncSequence and AsyncStream

An AsyncSequence is the asynchronous equivalent of Sequence — it produces values over time, consumed with for await:

swift
let notifications = NotificationCenter.default
    .notifications(named: .userSignedIn)
    .map { $0.userInfo }

for await info in notifications {
    // called each time the notification fires
    processUserInfo(info)
}

AsyncStream is a concrete type for producing an asynchronous sequence manually:

swift
let stream = AsyncStream<Int> { continuation in
    Task {
        for i in 1...10 {
            continuation.yield(i)
            try await Task.sleep(for: .seconds(1))
        }
        continuation.finish()
    }
}

for await value in stream {
    print(value)   // 1, 2, 3, … 10, one per second
}

AsyncStream supports buffering, cancellation (continuation.onTermination), and functional transforms (map, filter, compactMap). It's the async equivalent of a Publisher in Combine, built into the language with no framework dependency.

Usage note: For making async network calls with URLSession, see Intermediate tier page 03. This page covers the language model behind it.

Comparison with Objective-C

Swift concurrencyObjective-C
Async modelasync/await (state machine)Blocks + dispatch queues
Thread managementCooperative poolManual queue creation
State protectionActors (serialized access)@synchronized, NSLock, serial queues
UI thread@MainActor (compiler-checked)dispatch_async(dispatch_get_main_queue(), …)
Data race preventionSendable (compile-time)Manual convention only
CancellationTask cancellation (propagated)[operation cancel] or dispatch_block_cancel
Value streamsAsyncSequence / AsyncStreamDelegate callbacks or KVO

Objective-C has no language-level concurrency model beyond GCD blocks and NSOperation. Actors, Sendable, and structured concurrency are all Swift-exclusive. The equivalent of a Swift actor in ObjC is a class with a serial dispatch queue or a pthread_mutex_t — manually managed and error-prone.

What's next

You can now write safe concurrent code with async/await, protect state with actors, annotate main-thread boundaries with @MainActor, and verify cross-concurrency safety with Sendable. The next page covers Swift Package Manager — how to structure code into modules, declare dependencies, and publish libraries.

NextSwift Package Manager