Swift Concurrency: A Practical Guide from GCD to Actors
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
@mainentry 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:
- No inheritance
- 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:
- Shared mutable reference types outside the actor
- Multiple actors sharing the same reference
- 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
- Avoid accessing mutable state in
nonisolatedmembers - Prefer value types over shared reference types
- Be cautious when creating tasks inside actors
- 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.