Swift Concurrency cheat sheet: A Dive into Async/Await, Actors, and More
Swift has been evolving rapidly, and with the recent updates, concurrency has taken a massive leap forward. Apple has introduced several new concurrency features aimed at making it easier for developers to write asynchronous code that’s both safe and efficient. However, many of these new tools are still unfamiliar to some developers.
It is all new
If you haven’t had the time to explore these features yet or been busy with ongoing projects, this article is here to guide you through the essential Swift Concurrency concepts. We’ll dive into each feature in detail, with examples to help you understand how to use them.
We’ll delve deep into Swift’s concurrency model, covering:
async
/await
async let
Task
TaskGroup
Actors
/MainActor
Sendable
- Continuations
AsynSequence
AsyncStream
For each topic, I’ll provide detailed explanations, advanced examples, execution order and serialization insights, best practices, and common pitfalls to help you master these features.
Each topic consists of these sub-topics:
- Background
- How it works
- Examples
- Execution Order and Serialization
- Best Practices
- Common Pitfalls
- Migrating from Combine
The order of these sub-topics will remain consistent for easier readability.
Combine
For those more familiar with Combine or who prefer it over callback-based patterns, you’ll also find examples demonstrating how to easily migrate Combine code to Swift Concurrency.
Swift has evolved significantly since its launch, continually introducing features that make developers’ lives easier and code more efficient. One of the most impactful advancements is the introduction of modern concurrency features.
ℹ️ This article is quite detailed and extensive, and is not split into separate articles, so you may want to bookmark it for quick reference or to read at your convenience at a later time.
1. Async/Await
Background
Before async
/await
, Swift developers relied heavily on completion handlers, delegates, or closure-based callbacks to handle asynchronous operations. While functional, these approaches often led to nested closures and callback hell, making code difficult to read and maintain.
How it Works
- Async Functions: When you mark a function with
async
, you're indicating that the function contains asynchronous code and can suspend execution. - Awaiting Results: The
await
keyword pauses the execution of your code until the asynchronous function returns a result. - Under the Hood: Swift uses continuations to save the state of your function when it’s suspended, allowing other code to run in the meantime.
Example
Consider an app that needs to perform several network requests in sequence:
func fetchUserProfile(userID: String) async throws -> UserProfile {
let user = try await fetchUser(id: userID)
let posts = try await fetchPosts(for: user)
let comments = try await fetchComments(for: posts)
return UserProfile(user: user, posts: posts, comments: comments)
}
Explanation
- Sequential Execution: Each
await
waits for the previous network call to complete before proceeding. - Error Handling: By combining
async
withthrows
, you can handle errors usingdo-catch
blocks, making error handling straightforward.
Execution Order and Serialization
When you use await
, the code effectively pauses at that point until the asynchronous function returns. This means your code executes sequentially unless you explicitly introduce concurrency.
Example
Suppose you need to fetch data from two sources.
func processData() async throws {
let data1 = try await fetchData1()
let data2 = try await fetchData2()
// data2 is fetched only after data1 is fetched
let result = process(data1, data2)
print(result)
}
Explanation
- Sequential:
fetchData1()
completes beforefetchData2()
starts. - Serialization: Each
await
ensures the previous asynchronous call has completed before moving on.
Best Practices
- Avoid Blocking the Main Thread: Use
async
functions to prevent blocking the main thread, especially in UI applications. - Structured Concurrency: Organize your asynchronous code logically, making it easier to read and maintain.
- Use Concurrency Wisely: Introduce concurrency only when it benefits performance.
Common Pitfalls
- Deadlocks: Be cautious of potential deadlocks when awaiting code that synchronously waits for the main thread.
- Overuse of
await
: Excessive use in tight loops can lead to performance issues. Consider using asynchronous sequences or task groups.
✈️ Migrating from Combine?
In Combine, you would often use publishers and subscribers for asynchronous work. With async
/await
, you can replace these with more straightforward syntax for handling asynchronous tasks.
Example
func fetchData() -> AnyPublisher<Data, URLError> {
URLSession.shared.dataTaskPublisher(for: URL(string: "https://example.com")!)
.map(\.data)
.eraseToAnyPublisher()
}
fetchData()
.sink(
receiveCompletion: { _ in },
receiveValue: { data in
print("Received data: \(data)")
}
)
.store(in: &cancellables)
Can be replaced with:
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://example.com")!)
return data
}
do {
let data = try await fetchData()
print("Received data: \(data)")
} catch let error {
print("Error fetching data: \(error)")
}
2. Async Let
Background
async let
creates child tasks that run concurrently with the current task. Variables declared with async let
are scoped to the current function, and their lifetimes are tied to it.
How it Works
- Async Concurrent Child Tasks:
async let
creates child tasks that run concurrently with the current task. - Scoping: Variables declared with
async let
are scoped to the current function. - Awaiting Results: You must
await
the variables before using them.
Example
Suppose you need to fetch two images simultaneously.
func fetchImage1() async throws -> Image {
// Fetch image from server
}
func fetchImage2() async throws -> Image {
// Fetch image from server
}
func fetchBothImages() async throws -> (Image, Image) {
async let image1 = fetchImage1()
async let image2 = fetchImage2()
return try await (image1, image2)
}
Explanation
- Concurrent Execution:
fetchImage1()
andfetchImage2()
start simultaneously. - Awaiting Results: The
try await
expression waits for both images to be fetched.
Execution Order and Serialization
When you declare variables with async let
, the associated asynchronous tasks start immediately and run concurrently.
Example
func fetchDataConcurrently() async throws {
async let dataA = fetchDataA()
async let dataB = fetchDataB()
async let dataC = fetchDataC()
// The order of completion is not guaranteed
let results = try await [dataA, dataB, dataC]
}
Explanation
- Non-Serialized: Tasks start at the same time and run concurrently.
- Order of Completion: Tasks may complete in any order.
- Awaiting Results: You wait for all tasks to complete before proceeding.
- Efficiency: Reduces total execution time compared to running them sequentially.
Best Practices
- Limit Concurrent Tasks: Be mindful of system resources when spawning multiple concurrent tasks.
- Handle Dependencies: If tasks depend on each other, ensure proper sequencing by awaiting one before starting the next.
- Error Handling: Decide whether to cancel others or let them complete if one child task throws an error.
Common Pitfalls
- Resource Contention: Running too many tasks concurrently can strain system resources.
- Ignoring Cancellation: Child tasks created with
async let
cannot be cancelled individually. - Non-Determinism: Be aware that tasks may complete in any order.
✈️ Migrating from Combine?
In Combine, you may use multiple publishers concurrently and combine them with operators like combineLatest
. In Swift concurrency, async let
allows you to run multiple asynchronous operations in parallel.
Errors are handled within the publisher chain, using operators like catch
or sink
to capture errors. With async let
in Swift concurrency, errors can be caught using do-catch
blocks.
Example
let publisher1 = fetchData().eraseToAnyPublisher()
let publisher2 = fetchMoreData().eraseToAnyPublisher()
Publishers.Zip(publisher1, publisher2)
.sink(
receiveCompletion: { completion in
switch completion {
case .failure(let error):
print("Error occurred: \(error)")
case .finished:
print("Successfully completed.")
}
},
receiveValue: { data1, data2 in
print("Data1: \(data1), Data2: \(data2)")
}
)
.store(in: &cancellables)
Can be replaced with:
async let data1 = fetchData()
async let data2 = fetchMoreData()
do {
let (result1, result2) = try await (data1, data2)
print("Data1: \(result1), Data2: \(result2)")
} catch {
print("Error occurred: \(error)")
}
3. Task
Background
A Task
represents a unit of asynchronous work. It allows you to create new concurrent contexts, especially useful when starting asynchronous work from synchronous code.
How it Works
- Creating Tasks: Use
Task
to start asynchronous operations. - Cancellation: Tasks can be cancelled, and you should check for cancellation within long-running tasks.
- Task Hierarchy: Tasks can have parent-child relationships, inheriting context like priority and local values.
Example (1)
Starting a task from synchronous code.
func performAsyncWork() {
Task {
let data = try await fetchData()
print("Data fetched: \(data)")
}
}
Explanation
- Task Creation: The
Task
block starts an asynchronous task. - Concurrency: The task runs concurrently with the current code.
Example (2)
Handling cancellation in a long-running task.
func performLongRunningTask() {
let task = Task {
for i in 1...10000 {
// Perform work
print("Processing item \(i)")
}
}
// Cancel the task after some condition
if someCondition {
task.cancel()
}
}
Explanation
- Cancellation Handling: The task checks for cancellation and exits gracefully if cancelled.
- Task Reference: Keeping a reference allows you to cancel the task later.
Example (3)
Suppose you have an asynchronous function fetchWeatherData()
that retrieves weather information from a server.
func fetchWeatherData() async throws -> WeatherData {
// Simulate network delay
try await Task.sleep(nanoseconds: 1_000_000_000)
return WeatherData(temperature: 22, condition: "Sunny")
}
// Calling from an Asynchronous Context
func updateWeatherDisplay() async {
do {
let data = try await fetchWeatherData()
display(data)
} catch let error {
print("Failed to fetch weather data: \(error)")
}
}
// Calling from a Synchronous Context
func buttonPressed() {
Task {
do {
let data = try await fetchWeatherData()
display(data)
} catch let error {
print("Failed to fetch weather data: \(error)")
}
}
}
Explanation
- Synchronous Context: The
buttonPressed()
function is synchronous, perhaps tied to a button in UI. - Creating a Task: By using
Task { }
, you start an asynchronous operation from this synchronous function. - Asynchronous Work: Inside the
Task
closure, you can useawait
to callasync
functions. - Error Handling: Errors thrown by
fetchWeatherData()
are caught in thedo-catch
block inside the task.
Example (4)
Suppose you have two asynchronous functions, fetchUser()
and fetchPosts()
, and you want to perform actions after fetching the data.
func fetchUser() async throws -> User {
// Simulate fetching user data
return User(id: 1, name: "Alice")
}
func fetchPosts() async throws -> [Post] {
// Simulate fetching posts
return [Post(id: 101, title: "First Post")]
}
func loadData() {
print("Loading data...")
Task {
do {
let user = try await fetchUser()
print("User fetched: \(user.name)")
} catch {
print("Failed to fetch user: \(error)")
}
}
Task {
do {
let posts = try await fetchPosts()
print("Posts fetched: \(posts.count) posts")
} catch {
print("Failed to fetch posts: \(error)")
}
}
print("Tasks have been started")
}
/* Possible output:
Loading data...
Tasks have been started
User fetched: Alice
Posts fetched: 1 posts
*/
/* Another possible output:
Loading data...
Tasks have been started
Posts fetched: 1 posts
User fetched: Alice
*/
Explanation
- Concurrent Tasks: Two tasks are created to fetch the user and posts concurrently.
- Immediate Execution: Both tasks start immediately after being created.
- Non-blocking: The main function
loadData()
continues execution without waiting for the tasks to complete. - Error Handling: Each task handles its own errors independently.
Execution Order and Serialization
When you create a Task
, it begins executing concurrently with the current code.
Example
func executeTasks() {
Task {
print("Task 1 started")
await doWork1()
print("Task 1 completed")
}
Task {
print("Task 2 started")
await doWork2()
print("Task 2 completed")
}
print("executeTasks() function completed")
}
/* Possible output:
executeTasks() function completed
Task 1 started
Task 2 started
Task 1 completed
Task 2 completed
*/
Explanation
- Non-Serialized: Tasks run concurrently.
- Order of Execution: The completion order of tasks is not guaranteed.
Best Practices
- Cancellation Awareness: Always check for cancellation in tasks that might run for an extended period.
- Use Task Local Values: Pass values through the task hierarchy without explicitly threading them through parameters.
- Synchronize When Necessary: If tasks need to coordinate, use synchronization mechanisms or await on tasks.
Common Pitfalls
- Forgetting Cancellation Checks: Tasks may continue running after cancellation if not properly checked.
- Detached Tasks Misuse: Overusing detached tasks can lead to loss of context.
- Race Conditions: Ensure shared resources are protected.
✈️ Migrating from Combine?
Combine often uses Future
to perform asynchronous work. Task
in Swift concurrency is used to create units of work that can run asynchronously.
Example
func fetchData() -> Future<Data, Error> {
Future { promise in
URLSession.shared.dataTask(with: URL(string: "https://example.com")!) { data, _, error in
if let error = error {
promise(.failure(error))
} else {
promise(.success(data!))
}
}.resume()
}
}
fetchData()
.sink(
receiveCompletion: { _ in },
receiveValue: { data in
print("Received data: \(data)")
}
)
.store(in: &cancellables)
Can be replaced with:
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://example.com")!)
return data
}
Task {
do {
let data = try await fetchData()
print("Received data: \(data)")
} catch {
print("Error fetching data: \(error)")
}
}
4. TaskGroup
Background
TaskGroup
allows you to manage a dynamic group of tasks, making it easier to perform multiple asynchronous operations concurrently.
How it Works
- Structured Concurrency: Provides a way to manage concurrent tasks in a structured manner.
- Dynamic Task Management: You can dynamically add tasks to the group based on runtime conditions.
- Error Propagation: In a
withThrowingTaskGroup
, if a child task throws an error, the error propagates.
Example (1)
Suppose you have a range of numbers, and you want to compute the sum of their squares concurrently.
func sumOfSquares(upTo n: Int) async -> Int {
await withTaskGroup(of: Int.self) { group in
for i in 1...n {
group.addTask {
return i * i
}
}
var total = 0
for await result in group {
total += result
}
return total
}
}
Task {
let total = await sumOfSquares(upTo: 5)
print("Total sum of squares: \(total)")
}
/* Output:
Total sum of squares: 55
*/
Explanation
- Concurrent Execution: Each
sumOfSquares(n)
call runs concurrently. - Group Completion: The function waits until all tasks have completed.
Example (2)
Processing user requests with error handling.
func processUserRequests(_ requests: [UserRequest]) async throws -> [Response] {
try await withThrowingTaskGroup(of: Response.self) { group in
// Add each request to the task group
for request in requests {
group.addTask {
try await process(request) // Process each request concurrently
}
}
// Collect the responses as tasks complete
var responses: [Response] = []
for try await response in group {
responses.append(response)
}
return responses
}
}
Explanation
- Error Handling: This example uses
withThrowingTaskGroup
, so if any task throws an error while processing a user request, the error will propagate. However, the task group will still ensure that all tasks are completed (even those that don’t throw an error) before exiting. - Collecting Results: Responses are collected as tasks finish. The order of completion is not guaranteed since tasks run concurrently, but the results are accumulated in the order they are received.
Execution Order and Serialization
Tasks in a TaskGroup
run concurrently, and their completion order is not guaranteed.
Example
func fetchConcurrentData() async {
await withTaskGroup(of: String.self) { group in
group.addTask {
try? await Task.sleep(nanoseconds: 2_000_000_000)
return "Task 1 Completed"
}
group.addTask {
try? await Task.sleep(nanoseconds: 1_000_000_000)
return "Task 2 Completed"
}
group.addTask {
try? await Task.sleep(nanoseconds: 500_000_000)
return "Task 3 Completed"
}
// Collecting and printing results as tasks complete (order not guaranteed)
for await result in group {
print(result)
}
}
}
Task {
await fetchConcurrentData()
}
/* Possible output:
Task 3 Completed
Task 2 Completed
Task 1 Completed
*/
Explanation
- Parallel Execution: These tasks run concurrently.
- Non-Deterministic Completion: Since each task completes independently, the order is not guaranteed.
Best Practices
- Limit Task Group Size: Prevent resource exhaustion.
- Handle Errors Appropriately: Decide how to handle errors within tasks.
- Collect Results Carefully: If order matters, ensure results are stored correctly.
Common Pitfalls
- Forgetting to Await Results: Can lead to incomplete tasks.
- Resource Exhaustion: Adding too many tasks can strain resources.
- Error Propagation: Be mindful of how errors are handled.
✈️ Migrating from Combine?
In Combine, you would use operators like zip
or merge
to manage multiple publishers concurrently. TaskGroup
provides a structured way to manage and run multiple tasks concurrently in Swift concurrency.
Example
let publisher1 = fetchData()
let publisher2 = fetchMoreData()
publisher1
.zip(publisher2)
.sink(
receiveCompletion: { _ in },
receiveValue: { data1, data2 in
print("Data1: \(data1), Data2: \(data2)")
}
)
.store(in: &cancellables)
Can be replaced with:
func fetchAllData() async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await fetchData() }
group.addTask { try await fetchMoreData() }
var results = [Data]()
for try await result in group {
results.append(result)
}
return results
}
}
5. Actors
Background
Actors are a new reference type in Swift that protect access to their mutable state, ensuring thread safety.
How it Works
- State Protection: Actors ensure that their mutable state is accessed by only one task at a time.
- Isolation Domains: Each actor has its own queue; methods are executed serially.
- Reentrancy: Actors are reentrant by default.
Example (1)
Creating a simple counter actor.
actor Counter {
private var value = 0
func increment() {
value += 1
print("Value incremented to \(value)")
}
}
let counter = Counter()
Task {
await counter.increment()
}
Task {
await counter.increment()
}
/* Possible output:
Value incremented to 1
Value incremented to 2
*/
Explanation
- Serialized Access: The actor’s methods are run serially, ensuring that
value
is modified safely. Even though both tasks run concurrently, only one task can access the actor’s state at a time. - Thread Safety: Due to the actor’s internal isolation, multiple tasks can safely interact with the counter without risking race conditions.
Example (2)
Annotating an entire class.
@MainActor
class ViewModel {
var name: String = ""
func updateName(to newName: String) {
name = newName
print("Updated name to \(name)")
}
}
let viewModel = ViewModel()
Task {
await viewModel.updateName(to: "Swift")
}
Task {
await viewModel.updateName(to: "Concurrency")
/* Possible output:
Updated name to Swift
Updated name to Concurrency
*/
Explanation
- Main Thread Execution:
@MainActor
ensures that all methods inViewModel
are executed on the main thread, making it ideal for UI updates. - Serialized UI Updates: Methods like
updateName
are serialized on the main thread, so UI modifications are safe from race conditions.
Execution Order and Serialization
MainActor
ensures that code runs on the main thread and access is serialized.
Example
@MainActor
class LabelUpdater {
var labelText: String = ""
func updateLabel(with text: String) {
labelText = text
print("Label text updated to: \(labelText)")
}
}
let labelUpdater = LabelUpdater()
Task {
await labelUpdater.updateLabel(with: "Hello")
}
Task {
await labelUpdater.updateLabel(with: "World")
}
/* Possible output:
Label text updated to: Hello
Label text updated to: World
*/
Explanation
- Serialized Access: Although both tasks are concurrent, the actor ensures that the
updateLabel
method is executed one at a time, protecting access tolabelText
. - Order of Execution: The execution order depends on task scheduling, but only one task can modify the actor’s state at a time.
Best Practices
- Annotate Closures: Ensure closures that update UI are annotated with
@MainActor
. - Avoid Blocking Main Thread: Keep code lightweight.
- UI Updates: Use
MainActor
for UI interactions.
Common Pitfalls
- Unintentional Thread Hopping: Forgetting
await
can lead to code running on the wrong thread. - Performance Issues: Heavy computations can freeze the UI.
- Assuming Execution Order: Do not assume task execution order.
✈️ Migrating from Combine?
Actors provide thread-safe access to mutable state without needing to rely on Combine’s publishers for state management.
Example
class Counter: ObservableObject {
@Published var count = 0
}
let counter = Counter()
counter.$count
.sink { count in
print("Count: \(count)")
}
.store(in: &cancellables)
Can be replaced with:
actor Counter {
var count = 0
func increment() {
count += 1
}
}
let counter = Counter()
Task {
await counter.increment()
print("Count: \(await counter.count)")
}
✈️ Migrating from Combine?
Actors provide thread-safe access to mutable state without needing to rely on Combine’s publishers for state management.
Example (1)
class Counter: ObservableObject {
@Published var count = 0
}
let counter = Counter()
counter.$count
.sink { count in
print("Count: \(count)")
}
.store(in: &cancellables)
Can be replaced with:
actor Counter {
var count = 0
func increment() {
count += 1
}
}
let counter = Counter()
Task {
await counter.increment()
print("Count: \(await counter.count)")
}
In addition, in Combine, UI updates must occur on the main thread. MainActor
makes this simpler by enforcing that code runs on the main thread.
Example (2)
fetchData()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { _ in },
receiveValue: { data in
updateUI(with: data)
}
)
.store(in: &cancellables)
Can be replaced with:
@MainActor
func updateUI(with data: Data) {
// Update UI here
}
Task {
let data = try await fetchData()
await updateUI(with: data)
}
6. Sendable
Background
Sendable
is a protocol that indicates a type is safe to transfer across concurrency domains.
How it Works
- Concurrency Safety: Ensures data can be safely shared between concurrent tasks.
- Compiler Enforcement: The compiler checks for
Sendable
conformance. - Unchecked Sendable: Use
@unchecked Sendable
when manual verification is needed.
Example (1)
Defining a Sendable
struct.
struct Configuration: Sendable {
let setting: String
}
func updateConfig(_ config: Configuration) async {
// Use config safely across tasks
}
Explanation
- Immutable Type:
Configuration
is immutable and safe to share.
Example (2)
Creating a thread-safe class.
final class Logger: @unchecked Sendable {
private let queue = DispatchQueue(label: "logger.queue")
private var logs: [String] = []
func log(_ message: String) {
queue.async {
self.logs.append(message)
}
}
}
Explanation
- Manual Synchronization: Uses a dispatch queue to ensure thread safety.
- Unchecked Sendable: We declare conformance manually.
Execution Order and Serialization
Sendable
types can be safely transferred between tasks, but this does not affect execution order.
Example
struct DataPacket: Sendable {
let data: [Int]
}
func processData(_ packet: DataPacket) async {
print("Processing data: \(packet.data)")
}
let packet = DataPacket(data: [1, 2, 3])
Task {
await processData(packet)
}
Task {
await processData(packet)
}
Explanation
- Safe Sharing: The
DataPacket
structure conforms toSendable
, which means it can be safely transferred between different tasks without risk of data races. Swift ensures that no shared mutable state is modified concurrently. - Concurrent Execution: Both tasks can process the
DataPacket
concurrently. Even though theDataPacket
is safely shared between tasks, the order of task execution is determined by the system and is not guaranteed.
Best Practices
- Prefer Value Types: Use structs and enums for inherent thread safety.
- Careful with
@unchecked Sendable
: Ensure thread safety manually. - Immutability: Favor immutable types.
Common Pitfalls
- Assuming Thread Safety: Marking a type as
Sendable
doesn't make it thread-safe. - Mutable Reference Types: Be cautious with classes that have mutable properties.
- Ignoring Execution Order: Recognize that tasks run concurrently.
✈️ Migrating from Combine?
In Combine, thread safety is usually handled by manually ensuring that state is only accessed on certain threads (often by using DispatchQueue
) or by ensuring data is immutable. When using Swift Concurrency, Sendable
ensures types can be transferred between threads, but it does not inherently make mutable types thread-safe. However, managing mutable state still becomes simpler and safer. Instead of using DispatchQueue
, we can leverage Actors combined with data comformance to Sendable
to automatically enforce thread-safe access to mutable data.
Example
class User: ObservableObject {
@Published var name: String
init(name: String) {
self.name = name
}
func updateName(to newName: String) {
DispatchQueue.global().async {
self.name = newName
}
}
}
let user = User(name: "John")
let cancellable = user.$name
.receive(on: DispatchQueue.main)
.sink { name in
print("User name: \(name)")
}
// Simulate concurrent updates
DispatchQueue.global().async {
user.updateName(to: "Jane")
}
DispatchQueue.global().async {
user.updateName(to: "Doe")
}
Can be replaced with:
actor User: Sendable {
private(set) var name: String
init(name: String) {
self.name = name
}
func updateName(to newName: String) {
self.name = newName
}
func getName() -> String {
return self.name
}
}
let user = User(name: "John")
Task {
await user.updateName(to: "Jane")
print("User name: \(await user.getName())")
}
Task {
await user.updateName(to: "Doe")
print("User name: \(await user.getName())")
}
7. Continuations
Background
Continuations bridge the gap between traditional callback-based asynchronous code and Swift’s modern async/await
model, making it easier to integrate legacy APIs into Swift’s concurrency system.
How it Works
- Bridging Asynchronous APIs: Convert asynchronous callbacks into
async
functions. - Types of Continuations:
withCheckedContinuation
- for non-throwing callbacks,withCheckedThrowingContinuation
- for callbacks that may throw errors. - Resuming Continuations: You must always resume a continuation exactly once, either with a value or an error, depending on the API’s response. Resuming a continuation is needed because it signals the completion of an asynchronous operation in Swift’s concurrency model. When using continuations, you manually handle the transition from a callback-based asynchronous operation to Swift’s
async/await
model.
Example (1)
Adapting a callback-based function.
func legacyFetchData(completion: @escaping (Data?, Error?) -> Void) {
// Simulate a network call with a callback
}
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyFetchData { data, error in
if let data = data {
continuation.resume(returning: data)
} else if let error = error {
continuation.resume(throwing: error)
}
}
}
}
Explanation
- Bridging: Converts a callback-based API to an async function.
- Error Handling: Properly resumes with data or error. If an error occurs, it resumes with the error, maintaining proper error propagation.
Example (2)
Adapting a delegate-based API.
class NetworkDelegate: NSObject, URLSessionDataDelegate {
var continuation: CheckedContinuation<Data, Error>?
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
continuation?.resume(returning: data) // Resume continuation when data is received
}
}
func performNetworkRequest(url: URL) async throws -> Data {
let delegate = NetworkDelegate()
return try await withCheckedThrowingContinuation { continuation in
delegate.continuation = continuation // Set continuation reference
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
let task = session.dataTask(with: url)
task.resume() // Start the network request
}
}
Explanation
- Delegate Handling: This example adapts a delegate-based
URLSession
API by using a customNetworkDelegate
to manage the response. The delegate captures the incoming data and resumes the continuation. - Continuation Resumption: The continuation is resumed once the
URLSession
delegate method receives the data, ensuring safe handling of asynchronous operations.
Execution Order and Serialization
Continuations allow you to integrate callback-based code, but do not enforce execution order.
Example
func startProcess() {
Task {
let result = try await fetchData()
print("Data fetched")
}
print("Task started")
}
/* Possible output:
Task started
Data fetched
*/
Explanation
- Non-Blocking: The task runs concurrently.
- Execution Order: The order of execution depends on the completion of the asynchronous operation. The continuation allows concurrent processing but doesn’t dictate when callbacks finish.
Best Practices
- Ensure Single Resume: Resuming more than once leads to undefined behavior.
- Handle All Paths: Cover all possible execution paths.
- Handle Resumption Correctly: Maintain correct execution flow.
Common Pitfalls
- Multiple Resumes: Can cause crashes or undefined behavior.
- Forgotten Resumes: Can cause the function to hang.
- Understanding Asynchronicity: Recognize that legacy code may run on different threads.
✈️ Migrating from Combine?
In Combine, callback-based APIs are typically wrapped into Future
. With continuations, you can easily convert callback-based code to use Swift's async/await
.
Example
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
// Simulate network call
}
let future = Future<Data, Error> { promise in
fetchData { result in
promise(result)
}
}
future
.sink(
receiveCompletion: { _ in },
receiveValue: { data in
print("Received data: \(data)")
}
)
.store(in: &cancellables)
Can be replaced with:
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetchData { result in
continuation.resume(with: result)
}
}
}
Task {
do {
let data = try await fetchData()
print("Received data: \(data)")
} catch {
print("Error fetching data: \(error)")
}
}
8. AsyncSequence
Background
AsyncSequence
is a protocol for sequences that produce values over time asynchronously.
How it Works
- Asynchronous Iteration: Allows to iterate over elements asynchronously, awaiting each value as it becomes available.
- Implementing Protocols: Implement
AsyncSequence
andAsyncIteratorProtocol
.
Example (1)
Creating a simple asynchronous sequence.
struct Countdown: AsyncSequence {
typealias Element = Int
let start: Int
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(current: start)
}
struct AsyncIterator: AsyncIteratorProtocol {
var current: Int
mutating func next() async -> Int? {
guard current > 0 else { return nil } // Stop when countdown hits zero
let value = current
current -= 1
return value // Return the next countdown value
}
}
}
Task {
for await number in Countdown(start: 3) {
print(number)
}
}
/* Prints:
3
2
1
*/
Explanation
- Asynchronous Iteration: The
for await
loop iterates over theCountdown
sequence, awaiting each number as it becomes available. - Sequence Completion: The sequence stops and returns
nil
whencurrent
reaches zero, signaling the end of the iteration. - Type Safety with
Element
: Thetypealias Element = Int
declares that theCountdown
sequence will yieldInt
values. Even thoughElement
isn’t directly referenced in the code, it ensures that Swift knows the type of values the sequence will produce. Thenext()
function returnsInt?
, which is consistent with theElement
type. - Using Other Types: While this example uses
Int
, you can implementAsyncSequence
with other types as well, such asString
,Double
, or custom types. For example, if you wanted a sequence that yields strings, you could replacetypealias Element = Int
withtypealias Element = String
and adjust the implementation accordingly. This flexibility allowsAsyncSequence
to work with any type of element. - Guaranteed Order: The sequence yields values in descending order, starting from the initial value (
start
) down to1
.
Example (2)
Reading lines from a file asynchronously.
struct FileLineSequence: AsyncSequence {
typealias Element = String
let url: URL
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(url: url)
}
struct AsyncIterator: AsyncIteratorProtocol {
let fileHandle: FileHandle?
init(url: URL) {
fileHandle = try? FileHandle(forReadingFrom: url) // Open the file
}
mutating func next() async throws -> String? {
guard let handle = fileHandle else { return nil } // Stop if file can't be read
let data = try await handle.read(upToCount: 1024) // Read a chunk of data
return data.flatMap { String(data: $0, encoding: .utf8) } // Convert data to String
}
}
}
Task {
for try await line in FileLineSequence(url: fileURL) {
print(line)
}
}
Explanation
- Asynchronous File Reading: Reads file lines without blocking.
- Resource Management: The file is accessed and managed efficiently using
FileHandle
, and the file is read asynchronously.
Execution Order and Serialization
Elements are yielded in a specific order defined by the sequence.
Example
struct NumberSequence: AsyncSequence {
typealias Element = Int
let numbers: [Int]
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(numbers: numbers)
}
struct AsyncIterator: AsyncIteratorProtocol {
var index = 0
let numbers: [Int]
mutating func next() async -> Int? {
guard index < numbers.count else { return nil } // Stop when the array is exhausted
let number = numbers[index]
index += 1
return number // Continue with the next number
}
}
}
Task {
for await number in NumberSequence(numbers: [1, 2, 3]) {
print(number)
}
}
/* Possible output:
1
2
3
*/
Explanation
- Serialized Access: Elements are processed in order.
- Awaiting Next Element: The loop waits for each element.
Best Practices
- Manage Resources: When working with external resources (e.g., files or network connections), ensure resources are properly released when the sequence ends.
- Handle Cancellation: Be prepared to handle early termination if the task awaiting the sequence is canceled.
- Maintain Order: Process elements in the correct order, as asynchronous sequences may be processed concurrently, but each element is processed in the order specified by the sequence.
Common Pitfalls
- Ignoring Backpressure: Ensure that consumers of the
AsyncSequence
can handle the pace at which values are produced. Overwhelming the consumer can lead to issues. - Not Handling Errors: Ensure proper error handling.
- Handle Delays Appropriately: Since each
await
suspends the current task until the next element is ready, understand that the processing may be delayed if the sequence takes time to produce each value.
✈️ Migrating from Combine?
Combine’s Publisher
is very similar to AsyncSequence
in Swift concurrency. They both provide values over time, but AsyncSequence
uses async/await
instead of Combine's operator chains.
Example
let publisher = [1, 2, 3].publisher
publisher
.sink { value in
print(value)
}
.store(in: &cancellables)
Can be replaced with:
let asyncSequence = [1, 2, 3].async
for await value in asyncSequence {
print(value)
}
9. AsyncStream
Background
AsyncStream
helps you create an AsyncSequence
from a series of events or values, especially useful when dealing with delegate or callback patterns.
How it Works
- Producer-Consumer Model: Allows you to produce values asynchronously.
- Continuation Control: You control when to process values and finish the stream.
- Buffering Policies: You can control how values are buffered (if at all) before they are consumed.
Example (1)
Creating a stream from timer events.
func timerStream(interval: TimeInterval) -> AsyncStream<Date> {
AsyncStream { continuation in
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
continuation.yield(Date()) // Process the current date
}
continuation.onTermination = { _ in
timer.invalidate() // Invalidate the timer when the stream ends
}
}
}
Task {
for await time in timerStream(interval: 1.0) {
print("Time: \(time)")
}
}
Explanation
- Processing Values: The timer process the current date every second.
- Cleanup: When the stream ends,
onTermination
is triggered, which invalidates the timer to ensure proper resource cleanup.
Example (2)
Wrapping NotificationCenter
into an AsyncStream
.
func notificationStream(for name: Notification.Name) -> AsyncStream<Notification> {
AsyncStream { continuation in
let observer = NotificationCenter.default.addObserver(
forName: name,
object: nil,
queue: nil
) { notification in
continuation.yield(notification) // Process the notification
}
continuation.onTermination = { @Sendable _ in
NotificationCenter.default.removeObserver(observer) // Remove observer when stream ends
}
}
}
Task {
for await notification in notificationStream(for: .myCustomNotification) {
print("Received notification: \(notification)")
}
}
NotificationCenter.default.post(name: .myCustomNotification, object: nil, userInfo: ["info": "First Notification"])
NotificationCenter.default.post(name: .myCustomNotification, object: nil, userInfo: ["info": "Second Notification"])
/* Possible output:
Received notification: <NSNotification: 0x600001d029f0 {name = myCustomNotification; userInfo = {
info = "First Notification";
}}>
Received notification: <NSNotification: 0x600001d028d0 {name = myCustomNotification; userInfo = {
info = "Second Notification";
}}>
*/
Explanation
- Yielding Notifications: Every time a matching notification is posted, it is yielded to the consumer.
- Cleanup: The observer is removed from
NotificationCenter
when the stream terminates to avoid memory leaks.
Execution Order and Serialization
The order of elements depends on when they are yielded.
Example
func randomNumberStream() -> AsyncStream<Int> {
AsyncStream { continuation in
for _ in 1...5 {
DispatchQueue.global().asyncAfter(deadline: .now() + .random(in: 1...3)) {
continuation.yield(Int.random(in: 1...100)) // Yield random numbers
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + 4) {
continuation.finish() // Finish the stream after all numbers are produced
}
}
}
Task {
for await number in randomNumberStream() {
print("Received number: \(number)")
}
}
/* Possible output:
Received number: 42
Received number: 7
Received number: 89
Received number: 23
Received number: 56
*/
Explanation
- Non-Serialized Emission: Numbers are yielded at random intervals, so they can appear in any order.
- Consumer Order: The consumer processes elements in the order they are received, which might not match the order in which they were created.
Best Practices
- Handle Termination: Use
continuation.onTermination
to clean up resources, such as timers, observers, or network connections, to avoid memory leaks. - Choose Buffering Wisely: Manage memory by selecting appropriate buffering.
- Control Emission Timing: Ensure that values are yielded in a way that makes sense for your application, avoiding unnecessary delays or out-of-order data.
Common Pitfalls
- Memory Leaks: Forgetting to clean up resources.
- Unbounded Buffers: If values are buffered indefinitely and the consumer can’t keep up, memory usage can grow out of control.
- Unordered Data: Because
AsyncStream
allows values to be yielded at different times, ensure that your application logic can handle out-of-order elements if necessary.
✈️ Migrating from Combine?
AsyncStream
is useful when you want to manually push events into a stream, similar to using a PassthroughSubject
in Combine.
Example
let subject = PassthroughSubject<Int, Never>()
subject
.sink { value in
print(value)
}
.store(in: &cancellables)
subject.send(1)
subject.send(2)
subject.send(3)
Can be replaced with:
let stream = AsyncStream<Int> { continuation in
continuation.yield(1)
continuation.yield(2)
continuation.yield(3)
continuation.finish()
}
Task {
for await value in stream {
print(value)
}
}
Conclusion
Understanding Swift’s modern concurrency features is crucial for writing efficient, safe, and maintainable asynchronous code. These tools not only simplify complex asynchronous patterns but also introduce new paradigms for handling concurrency in Swift.
For developers familiar with Combine, transitioning to Swift concurrency provides a simpler, more intuitive model for managing asynchronous tasks.
To sum up
async
/await
: Write asynchronous code that is as readable and intuitive as synchronous code, reducing the complexity of handling asynchronous operations.async let
and Task Groups: Run multiple asynchronous tasks concurrently with ease, allowing for efficient parallel execution and task coordination.- Actors and
MainActor
: Manage mutable state safely in a concurrent environment by using actors, and ensure UI updates happen on the main thread withMainActor
. Sendable
: Ensure that data shared between tasks is safe from data races by enforcing safe transfer of values across concurrency boundaries.- Continuations: Bridge existing callback-based asynchronous code into Swift’s modern concurrency model, converting legacy async patterns into cleaner
async/await
functions. AsyncSequence
andAsyncStream
: Handle asynchronous streams of data elegantly, allowing you to process and iterate over values as they become available.
Key Takeaways
- Explicit Control: Swift’s concurrency model provides powerful tools that give you explicit control over asynchronous operations and execution order, allowing you to design more predictable, manageable code.
- Be Mindful of Concurrency: Understand when tasks are running concurrently, and design your code to handle non-deterministic execution orders, ensuring your tasks work together correctly.
- Handle Errors and Cancellation: Always consider how your asynchronous code responds to potential errors and cancellation requests. Use
do-catch
and task cancellation mechanisms to handle unexpected scenarios gracefully. - Manage Resources: Clean up resources properly to avoid memory leaks or dangling tasks. Whether it’s managing continuations or finishing task groups, ensure your resources are handled efficiently.
- Test Thoroughly: Concurrency-related bugs can be subtle and difficult to detect. Comprehensive testing is essential to ensure your code handles asynchronous tasks correctly and avoids race conditions or unexpected behaviors.
Final word
By paying attention to these details, you’ll harness the full power of Swift’s concurrency model, leading to better-performing and more reliable applications.
Thank you for reading and good luck discovering new Swift concurrency-related features! :)
More about me and my experience here.