Effortless Concurrency in Swift with Async/Await

Most modern iOS apps need to run code asynchronously. 

Asynchronous code can be suspended and resumed later, allowing your app to keep its UI responsive while working on long tasks, like performing network requests to a REST API. 

You often run asynchronous code in parallel to make the best use of resources like the cores on your device’s CPU or internet bandwidth.

In this article, we will see how to run asynchronous functions in Swift and iOS apps using async/await.

Architecting SwiftUI apps with MVC and MVVM

GET THE FREE BOOK NOW

Table of Contents

Why do we need asynchronous functions in iOS apps?

When you learn Swift for the first time, your programs run from start to finish. Even if you split a large program into functions organized in structures or classes, the code will always run in the same sequence.

However, the nature of iOS apps is different, and they are not as exact. Most of the time, the app is idle, waiting for input. When the user interacts with the app or input is received from the device’s sensors, an event is triggered, and code gets executed. Some of this might be your code.

The code changes the app’s internal state by affecting its data or the state of its user interface (for example, by changing the scrolling position of a list). As a consequence, what appears on the screen must be updated to reflect the new app state.

To the user, all of these changes appear to happen simultaneously. But that’s not how computers work, and this is where asynchronous functions are helpful.

Blocking the main run loop for too long makes your app unresponsive

A CPU can only process instructions sequentially. Most of the instructions that make up your app run sequentially on a single core, even on modern CPUs with multiple cores.

Modern operating systems use complex techniques for time sharing, multitasking, and threading that make it possible to run code concurrently on a CPU that can only run instructions sequentially.

You can read about these techniques if you want, but you do not have to, as the technique used in an iOS app is a much simpler model. Any iOS app constantly cycles through a run loop which has three phases:

  • Receive input events.
  • Run code (possibly yours).
  • Update the UI.

the iOS run loop

This works well until you need to run code that takes a long time to execute.

Since each phase in the run loop runs in sequence only after the previous step is finished, code that takes too long to run delays the UI update and input phases. This makes your app’s user interface behave unevenly or even remain unresponsive for seconds.

ui update delayed by long-running code

This is where concurrency and asynchronous functions come to the rescue.

Asynchronous functions can perform long tasks without blocking the app’s main thread

When you call an asynchronous function, your code is suspended, leaving the app’s main thread free to run the app’s run loop.

The asynchronous function runs on a separate thread, taking as much time as it needs. When it has finished and returns a result, the system picks up your code where it stopped and continues to execute it.

running asynchronous code on a background thread

Code can take a long time to execute for two reasons:

  • It needs to run a computationally expensive algorithm, for example, a sorting algorithm on a large set of data, a search algorithm like A*, or a decision algorithm like Minimax.
  • It needs to wait for a slow process to complete, such as fetching data from a large database, collecting data from a device sensor, or transmitting data over a network connection.

Of all these possibilities, fetching data from the Internet is the most common reason for a delay in iOS apps. So, it’s no surprise that the URLSession class, the type from the Foundation framework that you use for network transfers, sports several asynchronous functions.

How to use asynchronous functions in Swift

Swift has built-in support for writing asynchronous code and running multiple asynchronous tasks sequentially or in parallel. 

Declaring asynchronous functions with async

In Swift, asynchronous functions are marked with the async keyword after the function name and before the return type. For example, this is the declaration of the data(from:delegate:) method of URLSession.

extension URLSession {
	public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws
	-> (Data, URLResponse) {
		//...
	}
}

Asynchronous functions can throw errors like any other function. In this case, the data(from:delegate:) method will throw an error when there are problems with the network transfer.

Most of the time, you will use asynchronous functions from the iOS SDK or other frameworks, so you don’t really care how they work internally and why they are asynchronous. Nevertheless, I will show you how you can make your own asynchronous functions.

Calling asynchronous functions with await

First, let’s look at how to use the asynchronous function. This example downloads a random image from the Dog API. You will find the complete code on GitHub.

The random image endpoint returns JSON data that we can model with a Codable structure.

struct Dog: Codable {
	let message: String
	let status: String

	var url: URL { URL(string: message)! }
}

We can then write a function to fetch a random dog from the API, using the data(from:delegate:) asynchronous method of URLSession.

func fetchDog() async throws -> Dog {
	let dogURL = URL(string: "https://dog.ceo/api/breeds/image/random")!
	let (data, _) = try await URLSession.shared.data(from: dogURL)
	return try JSONDecoder().decode(Dog.self, from: data)
}

Here you can see for the first time that you have to use the await keyword to call an async function. Since the data(from:delegate:) can also throw an error, we have to use the try keyword as well, as we would with any other throwing function.

Calling asynchronous functions sequentially

Our fetchDog() function only fetches some JSON data. What we want, though, is the actual image of a dog at the URL contained in the JSON data.

This means we have to make another network request when the first one finishes. This is as simple as calling two asynchronous functions one after the other.

func fetchImage() async throws -> UIImage {
	let dog = try await fetchDog()
	let (data, _) = try await URLSession.shared.data(from: dog.url)
	return UIImage(data: data)!
}

Each await keyword marks a suspension point in our code. When execution reaches it, it stops and gives back control of the main thread to the app’s run loop.

The called asynchronous function then runs on another thread, taking all the time it needs to complete the network transfer. When the data transfer finishes, the asynchronous function returns its result, and execution in our code resumes where it was suspended.

Thanks to the async/await concurrency model, the structure of our code looks the same as the structure of synchronous functions. This makes it easy for the reader to follow the execution flow of the code even when there are several asynchronous function calls.

Creating tasks to run asynchronous code in Swift and SwiftUI

Swift allows you to start asynchronous tasks inside the typical synchronous code that makes an app, including SwiftUI views. 

Bridging the gap between synchronous and asynchronous code

You might have noticed that every function that calls an async function must also be marked with the async keyword. The moment you mark a function as asynchronous, all the functions that call it must also be asynchronous, quickly spreading throughout your codebase.

However, most of the code that we write is synchronous. We need a way to overcome the barrier between synchronous and asynchronous code and interrupt the infinite upward chain of async functions.

We can do this by using the Task type.

Task {
	let image = try await fetchImage()
}

The initializer of the Task structure takes a sendable closure. You can call asynchronous functions using await inside this.

Tasks create an environment where you can execute and manage asynchronous work on a separate thread. You can run, pause, and cancel asynchronous code through the Task type.

The surrounding code remains synchronous, so the main execution is not suspended at the Task initialization but proceeds past it as usual. Suspension only happens at the await keyword inside the closure.

Calling asynchronous functions in SwiftUI

You can use the Task type to run asynchronous code in any synchronous code, for example, in a Swift Playground.

running asynchronous functions in an Xcode playground

More commonly, you will be triggering asynchronous code from SwiftUI views. You can use the Task type in this situation as well, either in the action closure of some view, for example, a button, or in a SwiftUI event view modifier like .onAppear.

Triggering network requests when a SwiftUI view appears is so common that SwiftUI even provides a .task modifier for that specific purpose.

struct ContentView: View {
	@State private var image: UIImage?

	var body: some View {
		VStack {
			if let image = image {
				Image(uiImage: image)
					.resizable()
					.aspectRatio(contentMode: .fit)
			}
			Button("Reload") {
				Task {
					image = try? await fetchImage()
				}
			}
		}
		.task {
			image = try? await fetchImage()
		}
	}
}

The advantage of using the .task modifier over the .onAppear modifier is not just its brevity. The .task modifier keeps track of the task it creates and cancels it when the relative view disappears from the screen. 

This is useful to automatically cancel any network request in progress when the user navigates back to a previous screen in an iOS app. 

Running several asynchronous functions in parallel

At times, you’ll need to run some asynchronous tasks in parallel to optimize the use of the CPU’s cores or internet bandwidth.

You can create separate Task instances to run asynchronous functions in parallel, but it is complicated to manage and synchronize the tasks as they finish.

To combat this, Swift offers structured concurrency.

You can run a discrete number of asynchronous functions simultaneously using async in front of let when creating a constant. This creates a let constant that receives the result of an asynchronous function when it finishes running.

For example, let’s fetch the JSON data for three dog images simultaneously from the remote API.

func fetchImages() async throws -> [UIImage] {
	async let first = fetchImage()
	async let second = fetchImage()
	async let third = fetchImage()
	return try await [first, second, third]
}

Notice that we didn’t use the `await` keyword when calling the asynchronous fetchImage() function this time.

We do not create suspension points when we use async let instead of await. Execution proceeds normally past each asynchronous call, starting an asynchronous task for each call.

We only use await at the end of the function when we need the content of all the constants to create the final array. Execution is suspended at that point only and resumes when all three results have been fetched.

Async/await, completion closures, and Combine

If you are (relatively) new to Swift and iOS development, concurrency through async/await should be your primary choice.

From Xcode 13.2 onwards, Swift concurrency is deployable back to iOS 13 and macOS Catalina 10.15, allowing your app to support at least two prior iOS versions as is customary in iOS development.

The following section is for you if you need to work with older OS versions or with codebases written before the introduction of concurrency in Swift 5.5 at WWDC2021. 

Callback-based asynchronous functions with completion closures

Before Swift concurrency, asynchronous functions used completion closures as callbacks. You can see one example in the old dataTask(with:completionHandler:) method of URLSession.

extension URLSession {
	open func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void)
	-> URLSessionDataTask {
		// ...
	}
}

Asynchronous functions with completion closures don’t suspend execution, so they need a completion closure that gets executed when the data transfer is completed.

Such closure gets the data and response as parameters. And since execution is not suspended, throwing errors does not work either, so any error is also passed to the closure as a parameter.

You then have to check such parameters in your code and handle each case appropriately. This is what it would look like if we were to rewrite our fetchDog() function to use completion closures.

struct HTTPError: Error {
	let statusCode: Int
}

func fetchDog(completion: @escaping (Result<Dog, Error>) -> Void) {
	let dogURL = URL(string: "https://dog.ceo/api/breeds/image/random")!
	let task = URLSession.shared.dataTask(with: dogURL) { data, response, error in
		if let error = error {
			completion(.failure(error))
			return
		}
		if let response = (response as? HTTPURLResponse), response.statusCode != 200 {
			completion(.failure(HTTPError(statusCode: response.statusCode)))
			return
		}
		do {
			let dog = try JSONDecoder().decode(Dog.self, from: data!)
			completion(.success(dog))
		} catch {
			completion(.failure(error))
		}
	}
	task.resume()
}

Our three-lines-long function is now a readability nightmare.

Being asynchronous, our function also needs to take a completion closure as a parameter, which we need to call on every branch in our code to report success and errors. And since we can’t use Swift error handling, we need to catch any thrown error and pass it to the completion closure as well.

Integrating callback-based asynchronous functions with async/await

While using async/await is clearly better, if you have a project full of callback-based asynchronous functions, you might not have the time to rewrite them all to use Swift concurrency.

So, are you condemned to use completion closures in your project forever?

Luckily, you aren’t. You can use checked continuations to transform your callback-based asynchronous functions into async functions.

func fetchDogAlternative() async throws -> Dog {
	return try await withCheckedThrowingContinuation { continuation in
		fetchDog() { result in
			continuation.resume(with: result)
		}
	}
}

If your completion closure does not use the Result type but separates data and errors into separate parameters, like the dataTask(with:completionHandler:) method of URLSession above, the CheckedContinuation type also has methods for returning values and throwing errors.

Managing asynchronous events with Combine or other FRP frameworks

At WWDC 2019, Apple introduced Combine, a native functional reactive programming (FRP) framework for iOS and other Apple platforms.

The proponents of FRP are often quick to highlight the fact that it has several uses. While that is true, in iOS, FRP frameworks like Combine or RxSwift before it are mainly used to manage asynchronous code.

This is not a controversial statement. It’s spelled out in the introduction of Apple’s documentation for Combine. On top of this, URLSession has a Combine interface for data task publishers. 

The reason why is easily understandable from the last example above. Our callback-based asynchronous function is already quite complex.

The situation is even worse when you have to call asynchronous functions sequentially. The only way to perform asynchronous functions sequentially when using callback closures is to nest them inside each other, leading to callback hell.

Async/await vs. Combine

FRP in general, and Combine in particular, is not as popular as some blogs might lead you to think. One way to gauge Combine’s popularity is to compare the number of questions about Combine on Stack Overflow to the number of questions about SwiftUI. You can also compare their trends on Google.

Granted, that is an imperfect method. Nonetheless, I think that with the introduction of Swift concurrency FRP will become less and less popular.

Some might argue that using Combine is better than standard callback-based asynchronous functions, but I disagree.

The simplicity of the async/await model, with the full power of Swift concurrency and the actor model, is a clear winner for me. And if there were still some doubts, I that think the new Swift Async Algorithms package, that replaces Combine’s most used features, clears them away.

Conclusions

In this article, we have seen how to use async/await in Swift to perform asynchronous functions, especially network requests, both sequentially and in parallel.

We also looked at how to bridge between synchronous and asynchronous code, including in SwiftUI.

In any complex app, simply calling asynchronous functions is not enough. It is also essential to know where to do this in an app’s architecture. To learn this, you can read my article on network requests and get my free guide on MVC and MVVM in SwiftUI in the box below.

Architecting SwiftUI apps with MVC and MVVM

It’s easy to make an app by throwing some code together. But without best practices and robust architecture, you soon end up with unmanageable spaghetti code. In this guide I'll show you how to properly structure SwiftUI apps.

GET THE FREE BOOK NOW

Leave a Comment