Swift 4 min read

Swift Concurrency: A Practical Guide from GCD to Actors

Swift Concurrency: A Practical Guide from GCD to Actors
Photo by Boston Public Library / Unsplash

Swift Concurrency is Apple’s modern concurrency model introduced to make asynchronous and concurrent programming safer, clearer, and easier to reason about.
This article walks through Swift Concurrency from a practical perspective, comparing it with Grand Central Dispatch (GCD) and covering async/await, parallelism, asynchronous sequences, tasks, task groups, cancellation, and actors.


1. Swift Concurrency vs. GCD (Grand Central Dispatch)

Both Swift Concurrency and GCD are used to handle concurrent tasks, but they differ significantly in philosophy and usage.

1.1 Design Philosophy

GCD
GCD is a low-level API built on a thread-pool model. Developers explicitly submit work to queues (serial or concurrent), and the system schedules those tasks onto threads. This approach gives fine-grained control but exposes developers to complexity around threads and synchronization.

Swift Concurrency
Swift Concurrency provides a higher-level abstraction based on async/await. Developers no longer manage queues or threads directly. Instead, concurrency is expressed declaratively using Task and actor, and the runtime handles scheduling and synchronization automatically—greatly reducing the risk of concurrency bugs.


1.2 Coding Style

GCD
GCD relies heavily on closures and callbacks, which often leads to deeply nested code (“callback hell”) in complex async flows.

DispatchQueue.global().async {
    // Background work
    DispatchQueue.main.async {
        // Update UI on main thread
    }
}

Swift Concurrency
With async/await, asynchronous code looks and reads like synchronous code, improving clarity and maintainability.

func fetchData() async {
    let data = await fetchAsyncData()
    // Continue processing
}

1.3 Error Handling

GCD
GCD does not provide built-in error handling. Errors must be manually propagated via callbacks or custom result types.

Swift Concurrency
Swift Concurrency integrates seamlessly with Swift’s try / catch model.

do {
    let result = try await someAsyncTask()
} catch {
    // Handle error
}

1.4 Task Cancellation

GCD
GCD has no native cancellation support. Cancellation must be implemented manually using flags or custom logic.

Swift Concurrency
Swift Concurrency provides cooperative cancellation via Task.

func longRunningTask() async {
    for i in 1...10 {
        if Task.isCancelled {
            print("Task cancelled at iteration \(i)")
            return
        }
        try? await Task.sleep(nanoseconds: 500_000_000)
    }
}

1.5 Data Synchronization

GCD
Developers must explicitly use locks, semaphores, or other synchronization primitives—often leading to deadlocks or race conditions.

Swift Concurrency
Swift introduces actors, which guarantee exclusive access to mutable state.

actor Counter {
    var count = 0
    func increment() {
        count += 1
    }
}

1.6 Performance and Scheduling

GCD
Developers control queue priorities and execution order manually.

Swift Concurrency
The runtime scheduler dynamically allocates resources based on task priority and system conditions. Developers specify intent using TaskPriority instead of managing threads directly.


Summary

  • GCD is suitable for low-level, highly customized concurrency control.
  • Swift Concurrency offers a modern, safer, and more expressive approach that fits most real-world scenarios.

2. Defining and Calling Asynchronous Functions

To declare an asynchronous function, add the async keyword. If the function can throw errors, async must appear before throws.

func fetchImages() async throws -> [Image] {
    try await Task.sleep(for: .seconds(2))
    return []
}

Calling async functions requires await:

do {
    let images = try await fetchImages()
    print("Fetched \(images.count) images")
} catch {
    print("Failed with error: \(error)")
}

Using await marks a suspension point, where execution may pause and the thread can be reused by other tasks.

Async functions can only be called from:

  • Other async functions
  • @main entry points
  • Unstructured tasks

3. Running Async Functions in Parallel

Sequential await calls suspend execution one at a time:

let a = await fetchImage(url: url1)
let b = await fetchImage(url: url2)

To run tasks in parallel, use async let:

async let first = fetchImage(url: url1)
async let second = fetchImage(url: url2)
async let third = fetchImage(url: url3)

let images = await [first, second, third]

Use async let when results are needed later; use await directly when subsequent logic depends immediately on the result.


4. Asynchronous Sequences (AsyncSequence)

AsyncSequence is the asynchronous counterpart of Sequence.

4.1 Custom AsyncSequence Example

struct Counter: AsyncSequence, AsyncIteratorProtocol {
    typealias Element = Int
    let limit: Int
    var current = 0

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

    func makeAsyncIterator() -> Self { self }
}

4.2 Iterating an AsyncSequence

for try await value in Counter(limit: 10) {
    print(value)
}

Returning nil from next() signals the end of the sequence.


5. Tasks and Task Groups

A Task is the basic unit of asynchronous work. All async code runs inside a task.

let task = Task {
    return "Hello from Task"
}
print(await task.value)

Task Priority

Swift provides priorities such as:

  • .high
  • .medium
  • .low
  • .background
  • .userInitiated
  • .utility

These guide the scheduler rather than enforcing strict execution order.


Task Groups

Task groups allow dynamically creating and managing multiple child tasks.

await withTaskGroup(of: Image.self) { group in
    for url in urls {
        group.addTask {
            await fetchImage(url: url)
        }
    }

    for await image in group {
        show(image)
    }
}

Tasks complete out of order, and results are processed as they become available.


Task Cancellation in Groups

Swift uses cooperative cancellation.

group.addTaskUnlessCancelled {
    guard !Task.isCancelled else { return nil }
    return await fetchImage(url: url)
}

This allows returning partial results instead of discarding completed work.


6. Actors

6.1 What Is an Actor?

Actors are reference types that serialize access to mutable state.

Key differences from classes:

  1. No inheritance
  2. Automatic data isolation
actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int

    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [measurement]
        self.max = measurement
    }

    func update(with value: Int) {
        measurements.append(value)
        max = max(max, value)
    }
}

Accessing mutable state from outside the actor requires await.


6.2 nonisolated Members

When a method or property does not touch isolated state, it can be marked nonisolated.

extension TemperatureLogger: CustomStringConvertible {
    nonisolated var description: String {
        "Logger label: \(label)"
    }
}

Constants do not need nonisolated—they are already thread-safe.


6.3 Why Data Races Can Still Happen with Actors

Actors prevent many data races, but not all:

  1. Shared mutable reference types outside the actor
  2. Multiple actors sharing the same reference
  3. Creating concurrent tasks inside an actor
actor Counter {
    var count = 0
    func incrementConcurrently() {
        Task { count += 1 }
        Task { count += 1 }
    }
}

6.4 Best Practices to Avoid Data Races

  1. Avoid accessing mutable state in nonisolated members
  2. Prefer value types over shared reference types
  3. Be cautious when creating tasks inside actors
  4. Use additional synchronization when necessary

Conclusion

Swift Concurrency represents a major step forward in concurrent programming on Apple platforms.
By combining async/await, structured concurrency, cooperative cancellation, and actors, Swift enables developers to write concurrent code that is both powerful and safe—without sacrificing readability.

If you are still relying heavily on GCD, migrating to Swift Concurrency is one of the most impactful improvements you can make to your Swift codebase.

End of entry · 60.391°N 5.322°E

Keep reading