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 letwhen the number of tasks is known at compile time, andTaskGroupwhen it’s dynamic. - Task cancellation is cooperative: Check
Task.isCancelledor calltry 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
@MainActorto ensure it runs on the main thread. - Sendable protocol: Data crossing actor boundaries must adopt
Sendable. Structs and enums are automaticallySendable. - Error handling: Call
async throwsfunctions withtry awaitinsidedo-catchblocks.