Async/Await & Actors in Swift
Introduce in swift 5.5 and iOS 15
Async/Await eliminate the pyramid of doom, callback hell and makes our code easier to read, maintain, and scale
Basically, scene A and scene B = Call API by closure.
Scene C and scene D= Calling API by Async/Await.
Scene E = Serial API call by nested closure.
Scene F= Serial API call by Async/Await.
Scene G = Parallel API call by Async/Await.
Overview
- Swift introduced
async
andawait
in version 5.5 to simplify handling asynchronous code. async
makes a function asynchronous, whileawait
is used to call these functions.
1. Defining async
Functions:
- Add the
async
keyword after the function signature. - The
await
keyword must be used when calling anasync
function to pause execution until it completes.
2. Blocking and Sequential Execution:
- Functions with
await
ensure sequential execution (i.e., the next line executes only after theawait
call finishes).
3. Using Task
to Avoid Infinite Loops:
- Wrapping calls inside a
Task
avoids the propagation ofasync
requirements throughout the call stack.
Parallel Execution
- Sequential calls can be converted to parallel calls using
async let
. - Example:
async let number1 = fetchNumber()
async let number2 = fetchNumber()
async let number3 = fetchNumber()
let numbers = await [number1, number2, number3]
- This runs the asynchronous calls in parallel, making the code more efficient.
Addressing Interview Questions
- How to handle parallel API calls in Swift? Use
async let
. - How to avoid callback pyramids? Use
async/await
to write linear, readable code. - How does
@MainActor
work? Ensures code runs on the main thread automatically for UI updates.
What is async?
Async stands for asynchronous and can be seen as a method attribute making it clear that a method performs asynchronous work. An example of such a method looks as follows:
func fetchImage() async throws -> [UIImage] {
// .. perform data request
}
The fetchImages
method is defined as async throwing, which means that it’s performing a failable asynchronous job. The method would return a collection of images if everything went well or throws an error if something went wrong.
What is await?
Await is the keyword to be used for calling async methods.
do {
let images = try await fetchImages()
print("Fetched \(images.count) images.")
} catch {
print("Fetching images failed with error \(error)")
}
Code example is performing an asynchronous task. Using the await keyword, we tell our program to await a result from the
fetchImages
method and only continue after a result arrived.
Problems solve by async/await
- Pyramid of doom.
- Better error handling.
- Conditional execution of an asynchronous function.
- Forgot or incorrectly call the callback.
Task.init {}
Perform the async task, we can initialize the priority of tasks like background, userInitiated, high, low, medium, utility.
What happened when async method call in viewDidLoad?
we can see the issue in the screenshot. we can use Task{} to solve the issue.
We can use priority in Task.init{} to perform the task in background, userInitiated, high, low, medium, utility.
Cancel a Task
Use of Async-await in an existing project
Just right click on the method, go to Refactor and convert your existing method to Async.
How can we perform by async/await like we have operationQueue
Here’s how you can replicate some OperationQueue
functionalities with async/await
:
1. Task Dependencies
In OperationQueue
, you can create dependencies between operations using addDependency()
. With async/await
, we can achieve this by simply awaiting the completion of one task before starting the next.
Example: Sequential Execution (Task Dependencies)
// Simulate async tasks with dependencies
func task1() async -> String {
await Task.sleep(2 * 1_000_000_000) // Sleep for 2 seconds
return "Task 1 completed"
}
func task2() async -> String {
await Task.sleep(1 * 1_000_000_000) // Sleep for 1 second
return "Task 2 completed"
}
func runSequentialTasks() async {
let result1 = await task1() // Wait for task1 to complete
print(result1)
let result2 = await task2() // Wait for task2 to complete
print(result2)
}
// Usage
Task {
await runSequentialTasks()
}
2. Parallel Execution
In OperationQueue
, you can execute operations in parallel by setting the maxConcurrentOperationCount
. With async/await
, you can use async let
or TaskGroup
to run multiple tasks concurrently.
Example: Parallel Execution with async let
// Simulate async tasks to run in parallel
func fetchData1() async -> String {
await Task.sleep(1 * 1_000_000_000) // Simulate network delay
return "Data 1 fetched"
}
func fetchData2() async -> String {
await Task.sleep(2 * 1_000_000_000) // Simulate network delay
return "Data 2 fetched"
}
func runParallelTasks() async {
async let data1 = fetchData1() // Start task1
async let data2 = fetchData2() // Start task2
// Await both tasks to complete
let result1 = await data1
let result2 = await data2
print(result1)
print(result2)
}
// Usage
Task {
await runParallelTasks()
}
3. Limiting Concurrent Tasks
OperationQueue
allows you to control the maximum number of concurrent tasks via the maxConcurrentOperationCount
property. In async/await
, you can use TaskGroup
to limit concurrency.
Example: Limiting Concurrent Tasks with TaskGroup
func fetchData(index: Int) async -> String {
await Task.sleep(1 * 1_000_000_000) // Simulate network delay
return "Fetched data \(index)"
}
func runLimitedConcurrency() async {
let group = TaskGroup<String>()
for i in 1...5 {
// Add task to the group
group.async {
return await fetchData(index: i)
}
}
// Await all tasks in the group
for await result in group {
print(result)
}
}
// Usage
Task {
await runLimitedConcurrency()
}
This example demonstrates how to run multiple asynchronous tasks while managing them in a TaskGroup
, which automatically waits for all tasks in the group to finish.
4. Task Cancellation
In OperationQueue
, tasks can be canceled using the cancel()
method. With async/await
, cancellation is done by checking Task.isCancelled
and manually canceling tasks using Task.cancel()
.
Example: Canceling Tasks
func performTask(index: Int) async {
for i in 1...5 {
if Task.isCancelled {
print("Task \(index) canceled")
return
}
await Task.sleep(1 * 1_000_000_000) // Simulate work
print("Task \(index) - Step \(i)")
}
}
func runCancellableTasks() async {
let task1 = Task { await performTask(index: 1) }
let task2 = Task { await performTask(index: 2) }
// Cancel task1 after 3 seconds
await Task.sleep(3 * 1_000_000_000)
task1.cancel()
// Wait for both tasks to complete or be canceled
await task1.value
await task2.value
}
// Usage
Task {
await runCancellableTasks()
}
In this example, task1
is canceled after 3 seconds, and the task checks periodically whether it has been canceled.
5. Handling Dependencies and Task Completion
If you need complex dependencies between tasks (e.g., task B depends on the result of task A), you can await one task’s result before starting the next.
Example: Handling Task Dependencies
func fetchDataA() async -> String {
await Task.sleep(1 * 1_000_000_000)
return "Data A"
}
func fetchDataB(dependentOn dataA: String) async -> String {
await Task.sleep(2 * 1_000_000_000)
return "Data B based on \(dataA)"
}
func runDependentTasks() async {
let dataA = await fetchDataA() // Task A completes first
let dataB = await fetchDataB(dependentOn: dataA) // Task B depends on Task A's result
print(dataB)
}
// Usage
Task {
await runDependentTasks()
}
Summary
While OperationQueue
offers a lot of advanced features for managing tasks (e.g., dependencies, cancellation, concurrency control), async/await
simplifies asynchronous code, making it easier to read and write. However, you can still achieve similar behavior:
- Task Dependencies: Achieved by
await
ing tasks one after the other. - Parallel Execution: Achieved with
async let
orTaskGroup
. - Concurrency Control: Managed using
TaskGroup
for concurrent execution with control. - Task Cancellation: Handled with
Task.cancel()
and checkingTask.isCancelled
.
For simpler task management, async/await
is sufficient. For more complex scenarios (like managing a large number of tasks, canceling specific tasks, or managing dependencies with intricate control), OperationQueue
might still be more appropriate.
Actor
In Swift, an actor is a reference type that helps to manage shared state (read or modify the same data) in concurrent programming. Actors were introduced in Swift 5.5.
Actor is Thread-safe and Isolated by default.
The actor’s state is isolated, meaning:
- Its mutable state can only be accessed or modified from within the actor itself.
- External code cannot directly access or modify the actor’s properties; it must go through the actor’s methods or computed properties.
Asynchronous Access:
- actor’s mutable state requires the use of
await
since tasks are queued sequentially.
How to use
Create actors using the actor keyword.
Actors can have properties, methods, initializers, and subscripts.
Actors can conform to protocols.
Actors can be generic.
Actors automatically conform to the Actor protocol.
Actors can be configured to use a specific SerialExecutor.
When to use
Actors are useful for building safe, concurrent abstractions in Swift code.
Actors are useful for managing shared state in programs.
Actors has two types of access:
- Isolated
- Non Isolated
Example in Swift
actor Account {
private var balance: Double = 0.0
func getBalance() -> Double {
return balance
}
func deposit(amount: Double) {
balance += amount
}
func withdraw(amount: Double) {
balance -= amount
}
}
// Usage
let account = Account()
// Concurrently accessing the account
Task {
await account.deposit(amount: 100)
}
Task {
await account.withdraw(amount: 50)
}
Task {
let currentBalance = await account.getBalance()
print("Balance: \(currentBalance)")
}
Why It’s Important
Without actors, handling the account balance state in a multi-threaded environment could lead to data corruption due to race conditions. By using actors, Swift ensures safe and predictable behavior for concurrent tasks.
Non Isolated
All access in the actor is isolated to avoid the data races by default. If we are sure about the property that it won’t raise data race, in that case we can set access level as Non isolated. Non isolated properties can be initialised and used like below,
Configuring an Actor for the Main Thread:
If your actor needs to update the UI, you can bind it to the main thread using @MainActor
.
@MainActor
actor MainThreadActor {
func updateUI() {
print("Running on the main thread")
}
}
let uiActor = MainThreadActor()
Task {
await uiActor.updateUI() // Guaranteed to run on the main thread
}
1. Actors Can Conform to Protocols
Actors can adopt protocols, just like classes or structs, but the protocol requirements must respect actor isolation. For example, methods that interact with an actor’s state should be marked as async
.
Example:
protocol Incrementable {
func increment() async
func getCount() async -> Int
}
actor Counter: Incrementable {
private var count: Int = 0
func increment() async {
count += 1
}
func getCount() async -> Int {
return count
}
}
// Usage
let counter = Counter()
Task {
await counter.increment()
print(await counter.getCount()) // Output: 1
}
2. Actors Can Be Generic
Actors can be made generic to handle different types of data. This is useful when you want a single actor to manage shared state for multiple types.
Example:
actor Storage<T> {
private var value: T
init(initialValue: T) {
self.value = initialValue
}
func updateValue(_ newValue: T) {
value = newValue
}
func getValue() -> T {
return value
}
}
// Usage
let stringStorage = Storage<String>(initialValue: "Hello")
Task {
await stringStorage.updateValue("World")
print(await stringStorage.getValue()) // Output: "World"
}
let intStorage = Storage<Int>(initialValue: 42)
Task {
await intStorage.updateValue(100)
print(await intStorage.getValue()) // Output: 100
}
3. Actors Automatically Conform to the Actor
Protocol
All actors automatically conform to the Actor
protocol. This protocol allows you to use general-purpose utilities that operate on any actor. You don't need to explicitly declare this conformance.
Example:
actor MyActor { }
func printActorType<T: Actor>(_ actor: T) {
print("This is an actor of type: \(type(of: actor))")
}
let actorInstance = MyActor()
printActorType(actorInstance) // Output: This is an actor of type: MyActor
4. Actors Can Be Configured to Use a Specific Serial Executor
Actors run on a serial executor, which controls how their tasks are executed. By default, Swift provides a suitable executor, but you can customize it (e.g., for running on the main thread).
Example:
@MainActor
actor UIActor {
func updateUI() {
print("Updating UI on the main thread.")
}
}
let uiActor = UIActor()
Task {
await uiActor.updateUI() // Runs on the main thread
}
- In the example above, the
@MainActor
attribute binds theUIActor
to the main thread, making it suitable for UI updates.