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.

Liudas Baronas
24 min readSep 16, 2024

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:

  1. async/await
  2. async let
  3. Task
  4. TaskGroup
  5. Actors/MainActor
  6. Sendable
  7. Continuations
  8. AsynSequence
  9. 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 with throws, you can handle errors using do-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 before fetchData2() 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() and fetchImage2() 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 use await to call async functions.
  • Error Handling: Errors thrown by fetchWeatherData() are caught in the do-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 in ViewModel 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 to labelText.
  • 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 to Sendable, 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 the DataPacket 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 custom NetworkDelegate 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 and AsyncIteratorProtocol.

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 the Countdown sequence, awaiting each number as it becomes available.
  • Sequence Completion: The sequence stops and returns nil when current reaches zero, signaling the end of the iteration.
  • Type Safety with Element: The typealias Element = Int declares that the Countdown sequence will yield Intvalues. Even though Element isn’t directly referenced in the code, it ensures that Swift knows the type of values the sequence will produce. The next() function returns Int?, which is consistent with the Element type.
  • Using Other Types: While this example uses Int, you can implement AsyncSequence with other types as well, such as String, Double, or custom types. For example, if you wanted a sequence that yields strings, you could replace typealias Element = Int with typealias Element = String and adjust the implementation accordingly. This flexibility allows AsyncSequence to work with any type of element.
  • Guaranteed Order: The sequence yields values in descending order, starting from the initial value (start) down to 1.

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 with MainActor.
  • 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 and AsyncStream: 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.

--

--

Liudas Baronas
Liudas Baronas

No responses yet