Polyglot
Languages Swift concurrency
Swift § concurrency

Concurrency

Swift 5.5 (2021) introduced structured concurrencyasync/await for asynchronous functions, Task for concurrent execution, TaskGroup for parallel work, and actor for isolated mutable state. Swift 6 (2024) introduced strict concurrency checking by default — admits substantial compile-time data-race protection through the Sendable protocol. The conventional contemporary form: async functions return values via suspension points (await); Tasks admit launching concurrent work; Actors admit isolating mutable state from data races; AsyncSequence admits asynchronous iteration. The combination — async/await, structured tasks, actors for isolation, the Sendable protocol for thread-safety, the strict concurrency checking — is the substance of Swift’s concurrency model.

async and await

A function marked async admits suspension points:

func fetchData(from url: URL) async throws -> Data {
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

// Calling:
let data = try await fetchData(from: url)

The await admits the runtime suspending the current task at this point — the calling thread is freed to do other work. When the awaited operation completes, the task resumes (possibly on a different thread).

async let

For concurrent execution of independent work:

func loadAll() async throws -> (User, [Post], [Comment]) {
    async let user = fetchUser()                   // starts immediately
    async let posts = fetchPosts()                  // starts immediately
    async let comments = fetchComments()            // starts immediately

    return try await (user, posts, comments)        // wait for all three
}

The async let admits parallel execution of independent operations — substantial concurrency without explicit task management.

Task

A Task admits launching a unit of asynchronous work:

Task {
    do {
        let data = try await fetch()
        process(data)
    } catch {
        log(error)
    }
}

// Detached (not inheriting actor context):
Task.detached {
    /* runs without inheriting the caller's actor context */
}

// Capturing return value:
let task = Task { () -> Int in
    try await fetchAndCompute()
}
let result = await task.value                       // waits for completion

Tasks are structured by default — they inherit the priority and task-local values of their creator.

For tasks that should not inherit:

Task.detached(priority: .background) {
    /* explicit, doesn't inherit */
}

TaskGroup

For substantial parallel work with substantial collection:

func fetchAll(urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask { try await fetch(url) }
        }

        var results: [Data] = []
        for try await data in group {              // collect as they complete
            results.append(data)
        }
        return results
    }
}

The withThrowingTaskGroup admits structured parallelism — all child tasks complete before the parent returns; on error, sibling tasks are cancelled.

For non-throwing version:

let results = await withTaskGroup(of: Int.self) { group in
    for i in 0..<10 {
        group.addTask { compute(i) }
    }

    var sum = 0
    for await value in group {
        sum += value
    }
    return sum
}

Cancellation

Tasks admit cancellation via cooperative checking:

let task = Task {
    for i in 0..<1000 {
        try Task.checkCancellation()                 // throws if cancelled
        try await processBatch(i)
    }
}

// Later:
task.cancel()                                       // signals cancellation

The Task.checkCancellation() throws CancellationError if cancelled.

For non-throwing checking:

let task = Task {
    for i in 0..<1000 {
        if Task.isCancelled { break }
        await processBatch(i)
    }
}

The conventional discipline checks for cancellation at substantial work boundaries.

Task.sleep

try await Task.sleep(for: .seconds(2))             // structured sleep
try await Task.sleep(for: .milliseconds(500))
try await Task.sleep(nanoseconds: 1_000_000_000)   // legacy

// In a retry pattern:
for attempt in 0..<3 {
    do {
        return try await fetch()
    } catch {
        if attempt < 2 {
            try await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))
        }
    }
}

Actors

An actor admits isolating mutable state — only one task at a time may access an actor’s mutable state:

actor Counter {
    private var count: Int = 0

    func increment() {
        count += 1                                  // safe: actor-isolated
    }

    func value() -> Int {
        count
    }
}

let counter = Counter()

await counter.increment()                           // await — calls cross actor boundary
let n = await counter.value()

Actor methods are implicitly async from outside the actor; non-async from inside (no need for await for self-references).

The mechanism admits substantial protection against data races — the compiler enforces actor isolation.

Multiple readers and writers

Actors admit only one task accessing mutable state at a time. For concurrent readers, the conventional substitute is immutable data or separate read-only views.

Actor isolation

actor BankAccount {
    private var balance: Double = 0

    func deposit(_ amount: Double) {
        balance += amount
    }

    func withdraw(_ amount: Double) throws {
        guard balance >= amount else {
            throw AccountError.insufficientFunds
        }
        balance -= amount
    }

    func transfer(to other: BankAccount, amount: Double) async throws {
        try withdraw(amount)                       // self-call; not async
        await other.deposit(amount)                // cross-actor; async
    }
}

The cross-actor calls (await other.deposit) admit substantial coordination through the await.

MainActor

The @MainActor admits “this code runs on the main thread”:

@MainActor
class ViewModel {
    var items: [Item] = []                          // main-actor isolated

    func update() {
        items.append(Item())                         // safe; on main thread
    }
}

// Calling from a non-MainActor context:
let vm = ViewModel()
await MainActor.run {
    vm.update()
}

// Or with a method:
@MainActor
func updateUI() {
    /* UI work */
}

Task {
    await updateUI()                                 // hop to main actor
}

The mechanism admits substantial UI thread safety — UI work runs on the main thread; the compiler enforces the contract.

Sendable

The Sendable protocol marks types as safe to share across concurrency boundaries:

struct Point: Sendable {                           // value-type with Sendable properties — safe
    let x: Double
    let y: Double
}

actor Counter {                                    // actors are implicitly Sendable
    var count: Int = 0
}

class Mutable {                                    // class is NOT Sendable by default
    var value: Int = 0
}

// Strict concurrency:
func send(_ value: Mutable) async {                // ERROR: Mutable is not Sendable
    /* ... */
}

For unchecked Sendable conformance (when the implementation guarantees safety):

final class ImmutableCache: @unchecked Sendable {
    let data: [String: Data]                        // immutable; safe via developer guarantee
    init(data: [String: Data]) {
        self.data = data
    }
}

Swift 6 admits strict concurrency checking by default — the compiler enforces Sendable constraints across concurrency boundaries.

The conventional discipline:

  • Use value types for substantial Sendable conformance (most are auto-Sendable).
  • Use actors for mutable shared state.
  • Use @MainActor for UI code.
  • Use @unchecked Sendable sparingly — admit only with substantial discipline.

AsyncSequence

The AsyncSequence protocol admits asynchronous iteration:

for try await line in fileHandle.bytes.lines {
    process(line)
}

for try await chunk in url.resourceBytes.chunks(ofCount: 1024) {
    write(chunk)
}

Custom AsyncSequence:

struct Counter: AsyncSequence {
    typealias Element = Int

    let max: Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 0
        let max: Int

        mutating func next() async -> Int? {
            try? await Task.sleep(for: .seconds(1))
            guard current < max else { return nil }
            defer { current += 1 }
            return current
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(max: max)
    }
}

for await n in Counter(max: 5) {
    print(n)                                        // 0, 1, 2, 3, 4 (one per second)
}

The conventional alternative is AsyncStream:

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

for await value in stream {
    print(value)
}

The AsyncStream admits substantial integration with closure-based APIs.

Common patterns

Sequential async

func process(items: [Item]) async throws -> [Result] {
    var results: [Result] = []
    for item in items {
        let r = try await processOne(item)         // sequential
        results.append(r)
    }
    return results
}

Parallel async

func process(items: [Item]) async throws -> [Result] {
    try await withThrowingTaskGroup(of: Result.self) { group in
        for item in items {
            group.addTask { try await processOne(item) }
        }

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

Bounded parallelism

func process(items: [Item], concurrency: Int) async throws -> [Result] {
    try await withThrowingTaskGroup(of: Result.self) { group in
        var results: [Result] = []
        var iter = items.makeIterator()

        // Initial batch:
        for _ in 0..<concurrency {
            guard let item = iter.next() else { break }
            group.addTask { try await processOne(item) }
        }

        // Replace as they complete:
        while let r = try await group.next() {
            results.append(r)
            if let item = iter.next() {
                group.addTask { try await processOne(item) }
            }
        }

        return results
    }
}

Timeout

func withTimeout<T>(_ duration: Duration, _ operation: @escaping () async throws -> T) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(for: duration)
            throw TimeoutError.exceeded
        }

        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

let data = try await withTimeout(.seconds(5)) {
    try await fetch()
}

Retry with exponential backoff

func with<T>(retries: Int, _ operation: @escaping () async throws -> T) async throws -> T {
    var lastError: Error?
    for attempt in 0..<retries {
        do {
            return try await operation()
        } catch {
            lastError = error
            if attempt < retries - 1 {
                try await Task.sleep(for: .seconds(pow(2.0, Double(attempt))))
            }
        }
    }
    throw lastError!
}

Cancellation propagation

func process(items: [Item]) async throws -> [Result] {
    var results: [Result] = []
    for item in items {
        try Task.checkCancellation()                 // check periodically
        let r = try await processOne(item)
        results.append(r)
    }
    return results
}

Producer-consumer with AsyncStream

func makeFeed() -> AsyncStream<Event> {
    AsyncStream { continuation in
        let observer = EventBus.subscribe { event in
            continuation.yield(event)
        }

        continuation.onTermination = { _ in
            EventBus.unsubscribe(observer)
        }
    }
}

let feed = makeFeed()
for await event in feed {
    handle(event)
}

Actor for thread-safe state

actor Cache {
    private var storage: [String: Data] = [:]

    func get(_ key: String) -> Data? {
        storage[key]
    }

    func set(_ key: String, _ value: Data) {
        storage[key] = value
    }

    func clear() {
        storage.removeAll()
    }
}

let cache = Cache()
await cache.set("key", data)
let retrieved = await cache.get("key")

@MainActor for UI

@MainActor
class TodoViewModel: ObservableObject {
    @Published var items: [Todo] = []
    @Published var isLoading: Bool = false

    func loadItems() async throws {
        isLoading = true                            // main thread
        defer { isLoading = false }

        let fetched = try await api.fetchTodos()
        items = fetched
    }
}

Concurrent map

extension Sequence where Element: Sendable {
    func concurrentMap<T: Sendable>(_ transform: @Sendable @escaping (Element) async throws -> T) async throws -> [T] {
        try await withThrowingTaskGroup(of: (Int, T).self) { group in
            for (index, element) in self.enumerated() {
                group.addTask { (index, try await transform(element)) }
            }

            var results = Array<T?>(repeating: nil, count: try await self.reduce(0, { count, _ in count + 1 }))
            for try await (index, value) in group {
                results[index] = value
            }
            return results.compactMap { $0 }
        }
    }
}

Continuations for callback bridging

extension URLSession {
    func data(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
        // legacy callback-based API
    }
}

func fetch(from url: URL) async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        URLSession.shared.data(from: url) { result in
            switch result {
            case .success(let data): continuation.resume(returning: data)
            case .failure(let error): continuation.resume(throwing: error)
            }
        }
    }
}

The withCheckedContinuation and withCheckedThrowingContinuation admit substantial bridging from callback-based APIs to async/await.

Task-local values

struct CurrentUser {
    @TaskLocal static var current: User?
}

func processRequest(user: User, work: () async throws -> Void) async throws {
    try await CurrentUser.$current.withValue(user) {
        try await work()
    }
}

// Inside the work:
let userID = CurrentUser.current?.id

The mechanism admits substantial context propagation across the async-await tree.

Task for fire-and-forget

class ViewController {
    func onTapButton() {
        Task {
            do {
                try await api.submitData()
            } catch {
                showError(error)
            }
        }
    }
}

Combine integration

For substantial reactive patterns, Combine (Apple’s reactive framework) admits substantial integration with async:

import Combine

let publisher = URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: User.self, decoder: JSONDecoder())

// As async:
for await user in publisher.values {
    print(user)
}

// Or a single value:
let user = try await publisher.values.first(where: { _ in true })!

A note on Swift 6 strict concurrency

Swift 6 (2024) admits complete concurrency checking by default:

  • Sendable conformance is required for cross-boundary sharing.
  • Actor isolation is enforced.
  • Data races are caught at compile time.

For migrating from Swift 5:

// Swift package manifest:
swiftSettings: [
    .enableExperimentalFeature("StrictConcurrency"),
]

The conventional contemporary discipline embraces strict concurrency for new code.

A note on the conventional discipline

The contemporary Swift concurrency advice:

  • Use async/await for asynchronous code.
  • Use Task for launching concurrent work.
  • Use TaskGroup for substantial parallelism.
  • Use async let for independent concurrent work.
  • Use actor for mutable shared state.
  • Use @MainActor for UI code.
  • Use Sendable — value types are conventional.
  • Use cancellation checks at substantial work boundaries.
  • Use Task.sleep for delays.
  • Use withCheckedContinuation for callback bridging.
  • Use AsyncSequence for streaming data.
  • Migrate to strict concurrency — Swift 6’s default.
  • Use Combine for substantial reactive patterns; async/await otherwise.

The combination — async/await, structured tasks, actors for isolation, Sendable for thread-safety, AsyncSequence for streaming, the strict concurrency checking — is the substance of Swift’s concurrency model. The discipline produces correct, performant, type-safe concurrent code with substantial protection against the conventional data-race pitfalls.