Introduction to Asynchronous Programming in Swift
Modern mobile and desktop applications in Swift (iOS/macOS) increasingly require performing long-running operations — loading data from the network, working with files, processing images — without blocking the user interface. Until recently, developers used closures (completion handlers) and Grand Central Dispatch (GCD), which led to the so-called "callback hell" and complicated code readability. With the release of Swift 5.5, Apple introduced a fundamentally new asynchronous programming model based on the keywords async and await, as well as actors and Task structures. In this guide, we will break down all the key concepts with practical examples.
1. Basics of async/await in Swift
The keyword async indicates that a function or method can suspend its execution to wait for the result of an asynchronous operation. A call to such a function is marked with the word await. This allows writing asynchronous code as linearly as synchronous code.
// Declaring an asynchronous functionfunc fetchUserData(from url: URL) async throws -> Data { let (data, _) = try await URLSession.shared.data(from: url) return data}
// Using it in a task contextTask { do { let url = URL(string: "https://api.example.com/user")! let userData = try await fetchUserData(from: url) print("Data received: \\(userData.count) bytes") } catch { print("Loading error: \\(error.localizedDescription)") }}Note: the await call suspends the current task but does not block the thread. The system itself switches context when the result is ready, making the code efficient and safe.
2. Actors — safe state management
Asynchrony introduces the problem of data races. To solve this, Swift introduced actors. An actor is a reference type that isolates its state and guarantees that only one task can access its mutable properties at any given time.
actor Counter { private var value = 0 func increment() { value += 1 } func getValue() -> Int { return value }}
// Using an actorlet counter = Counter()
Task { await counter.increment() let currentValue = await counter.getValue() print("Current value: \\(currentValue)")}The keyword actor automatically makes all methods and properties (except immutable ones) asynchronous for external access. This helps avoid locks and deadlocks typical of manual lock management (NSLock, DispatchSemaphore).
3. TaskGroup and parallel task execution
When you need to execute several independent asynchronous operations in parallel, use TaskGroup. This is a structure that allows launching child tasks and collecting their results.
func fetchMultipleUsers(ids: [Int]) async throws -> [User] { try await withThrowingTaskGroup(of: User.self) { group in var users: [User] = [] for id in ids { group.addTask { let url = URL(string: "https://api.example.com/users/\\(id)")! let data = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(User.self, from: data.0) } } for try await user in group { users.append(user) } return users }}
// UsageTask { let users = try? await fetchMultipleUsers(ids: [1, 2, 3, 4, 5]) print("Users loaded: \\(users?.count ?? 0)")}The method withThrowi