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

async/await
A description of how to call API using closure and async/await
Parallel API call using by async/await

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 and await in version 5.5 to simplify handling asynchronous code.
  • async makes a function asynchronous, while await 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 an async 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 the await call finishes).

3. Using Task to Avoid Infinite Loops:

  • Wrapping calls inside a Task avoids the propagation of async 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.

Task also provide the priority

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

Async and Await
How to use 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:

  1. Task Dependencies: Achieved by awaiting tasks one after the other.
  2. Parallel Execution: Achieved with async let or TaskGroup.
  3. Concurrency Control: Managed using TaskGroup for concurrent execution with control.
  4. Task Cancellation: Handled with Task.cancel() and checking Task.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 the UIActor to the main thread, making it suitable for UI updates.

Check with Github link for the Source code

https://github.com/Nirajpaul2/Async-Await-Example

--

--

No responses yet