Swift Concurrency — async/await and Actors

What Is Swift Concurrency?

Structured Concurrency, introduced in Swift 5.5, lets you write asynchronous code sequentially using async/await keywords, and prevents data races at compile time using Actor. It’s a modern asynchronous programming model that solves the complexity of callback hell and completion handlers.

This post covers the essentials from async/await basics to Task, TaskGroup, and Actor.

async/await Basics

The async keyword declares an asynchronous function, and the await keyword waits for the result of an asynchronous function.

import Foundation

// Declaring an async function
func fetchUser(id: Int) async -> String {
    // Async wait — does not block the thread
    try? await Task.sleep(for: .seconds(1))
    return "User_\(id)"
}

func fetchOrders(for user: String) async -> [String] {
    try? await Task.sleep(for: .milliseconds(800))
    return ["OrderA", "OrderB", "OrderC"]
}

// Async function that can throw errors
enum NetworkError: Error {
    case invalidURL
    case serverError(code: Int)
    case timeout
}

func fetchData(from urlString: String) async throws -> Data {
    guard let url = URL(string: urlString) else {
        throw NetworkError.invalidURL
    }
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw NetworkError.serverError(code: 500)
    }
    return data
}

// Main entry point
@main
struct App {
    static func main() async {
        // Sequential execution
        let user = await fetchUser(id: 1)
        print("User: \(user)")
        // Output: User: User_1

        let orders = await fetchOrders(for: user)
        print("Orders: \(orders)")
        // Output: Orders: ["OrderA", "OrderB", "OrderC"]

        // Parallel execution — async let
        let startTime = CFAbsoluteTimeGetCurrent()

        async let user1 = fetchUser(id: 1)
        async let user2 = fetchUser(id: 2)
        async let user3 = fetchUser(id: 3)

        // Await all results simultaneously
        let users = await [user1, user2, user3]
        let elapsed = CFAbsoluteTimeGetCurrent() - startTime

        print("Users: \(users)")
        print("Elapsed: \(String(format: "%.1f", elapsed))s (about 1s due to parallelism)")
        // Output:
        // Users: ["User_1", "User_2", "User_3"]
        // Elapsed: 1.0s (about 1s due to parallelism)
    }
}

Using async let to start multiple async tasks concurrently significantly reduces execution time compared to sequential execution.

Task and TaskGroup

Task is a unit of asynchronous work, and TaskGroup manages a dynamic number of tasks.

import Foundation

struct UserProfile {
    let name: String
    let score: Int
}

func fetchProfile(id: Int) async -> UserProfile {
    try? await Task.sleep(for: .milliseconds(500))
    return UserProfile(name: "User_\(id)", score: Int.random(in: 60...100))
}

@main
struct App {
    static func main() async {
        // Task — independent async work
        let task = Task {
            await fetchProfile(id: 1)
        }
        let profile = await task.value
        print("\(profile.name): \(profile.score) points")
        // Output: User_1: 87 points (random value)

        // Task cancellation
        let longTask = Task {
            for i in 1...10 {
                // Check for cancellation — cooperative cancellation
                guard !Task.isCancelled else {
                    print("Task cancelled (i=\(i))")
                    return
                }
                try? await Task.sleep(for: .milliseconds(200))
                print("Progress: \(i)/10")
            }
        }
        try? await Task.sleep(for: .milliseconds(500))
        longTask.cancel()  // Request cancellation
        await longTask.value
        // Output:
        // Progress: 1/10
        // Progress: 2/10
        // Task cancelled (i=3)

        // TaskGroup — parallel tasks with dynamic count
        let userIds = [1, 2, 3, 4, 5]

        let profiles = await withTaskGroup(
            of: UserProfile.self,
            returning: [UserProfile].self
        ) { group in
            // Fetch profiles in parallel for all IDs
            for id in userIds {
                group.addTask {
                    await fetchProfile(id: id)
                }
            }

            // Collect results
            var results: [UserProfile] = []
            for await profile in group {
                results.append(profile)
            }
            return results
        }

        print("\nAll profiles:")
        for p in profiles.sorted(by: { $0.score > $1.score }) {
            print("  \(p.name): \(p.score) points")
        }
        // Output (random values, sorted by score descending):
        // All profiles:
        //   User_3: 95 points
        //   User_1: 88 points
        //   User_5: 82 points
        //   User_2: 76 points
        //   User_4: 71 points
    }
}

TaskGroup is ideal for running a dynamic number of tasks in parallel and collecting results. async let is better suited for a static number of tasks.

Actor — Preventing Data Races

Actor serializes access to its state, preventing data races at compile time. It’s similar to a class, but accessing properties or methods from outside requires await.

import Foundation

// Actor — protects state from concurrent access
actor BankAccount {
    let owner: String
    private(set) var balance: Int

    init(owner: String, balance: Int) {
        self.owner = owner
        self.balance = balance
    }

    func deposit(_ amount: Int) {
        balance += amount
        print("\(owner): +\(amount) (balance: \(balance))")
    }

    func withdraw(_ amount: Int) -> Bool {
        guard balance >= amount else {
            print("\(owner): Insufficient balance (balance: \(balance), requested: \(amount))")
            return false
        }
        balance -= amount
        print("\(owner): -\(amount) (balance: \(balance))")
        return true
    }

    // nonisolated — accessible without actor isolation (immutable data only)
    nonisolated var accountInfo: String {
        return "Account owner: \(owner)"
    }
}

// @MainActor — guarantees execution on the main thread
@MainActor
class ViewModel {
    var statusMessage = ""

    func updateStatus(_ message: String) {
        statusMessage = message
        print("[UI Update] \(message)")
    }
}

@main
struct App {
    static func main() async {
        let account = BankAccount(owner: "Alice", balance: 100000)

        // Actor methods require await
        await account.deposit(50000)
        // Output: Alice: +50000 (balance: 150000)

        let success = await account.withdraw(30000)
        print("Withdrawal successful: \(success)")
        // Output:
        // Alice: -30000 (balance: 120000)
        // Withdrawal successful: true

        // Safe even when multiple tasks access concurrently
        await withTaskGroup(of: Void.self) { group in
            for i in 1...5 {
                group.addTask {
                    await account.deposit(i * 1000)
                }
            }
        }

        let finalBalance = await account.balance
        print("Final balance: \(finalBalance)")
        // Output: Final balance: 135000 (120000 + 1000+2000+3000+4000+5000)

        // nonisolated property does not require await
        print(account.accountInfo)
        // Output: Account owner: Alice
    }
}

Actors let you write thread-safe code without mutexes or locks. The compiler enforces isolation rules, catching potential data races as compile-time errors.

Practical Tips

  • async let vs TaskGroup: Use async let when the number of tasks is known at compile time, and TaskGroup when it’s dynamic.
  • Task cancellation is cooperative: Check Task.isCancelled or call try Task.checkCancellation() to cooperate with cancellation.
  • Actors are reference types: Actors are passed by reference like classes. Use structs when you need value types.
  • Protect UI with @MainActor: Mark UI-related code with @MainActor to ensure it runs on the main thread.
  • Sendable protocol: Data crossing actor boundaries must adopt Sendable. Structs and enums are automatically Sendable.
  • Error handling: Call async throws functions with try await inside do-catch blocks.

Was this article helpful?