Pre Swift 5.5, you used GCD to run asynchronous code via dispatch queues — an abstraction over threads.
GCD’s queue-based model worked well. However, it would cause issues, like:
Concurrency means that different pieces of code run at the same time.
The new concurrency model is tightly integrated with the language syntax, the Swift runtime and Xcode. It abstracts away the notion of threads for the developer. Its key new features include:
Simple example
SwiftUI views offer support for calling asynchronous functions. Also, while a function is concurrently executing, we can change the view without having it blocked.
In this short snippet, we'll integrate an async function that suspends its execution for a few seconds, and at the same time, we can use a button to increase a counter shown in the view.
Create a Service
class with the following two functions:
class Service { func fetchResult() async -> String { await sleep(seconds: 5) return "Result" } private func sleep(seconds: Int) async { try? await Task.sleep(nanoseconds: 2000000000) // wait 2 seconds } }
The async
keyword in the method’s definition lets the compiler know that the code runs in an asynchronous context. In other words, it says that the code might suspend and resume at will. Also, regardless of how long the method takes to complete, it ultimately returns a value much like a synchronous method does.
In ContentView
, add two @State
properties, as shown in the following code:
struct ContentView: View { @State var value: String = "" @State var counter = 0 let service = Service() var body: some View { VStack { Text(value) Text("\(counter)") Button { counter += 1 } label: { Text("increment") } .buttonStyle(.bordered) } .task { value = await service.fetchResult() } } }
To update a view, SwiftUI requires that the code runs in the main thread. .task{}
does this for us by moving the code execution from the background thread to the main thread.
So, the async
keyword defines a function as asynchronous. await
lets you wait in a non-blocking fashion for the result of the asynchronous function.
Routing code to the main thread
One of possible way to ensure your code is on the main thread is calling MainActor.run()
.
await MainActor.run { ... your UI code ... }
MainActor
is a type that runs code on the main thread. It’s the modern alternative to the well-known DispatchQueue.main
, which you might have used in the past.
Fetching remote data in SwiftUI
One of the operations made easier by the async/await model is network operations. Fetching data from a network resource is an asynchronous operation by definition and until now, we had to manage it with the callback mechanism. iOS 15 adds an async/await interface to the URLSession class to embrace the new pattern.
Create the model objects to transform the JSON returned from the network call to Swift struct
struct Post: Codable { let userId: Int let id: Int let title: String let body: String }
Create a Service
struct to call the network request and decode the response into a Decodable
model:
struct PostService { private let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder }() private func fetch<T: Decodable>(type: T.Type, from urlString: String) async -> T? { guard let url = URL(string: urlString) else { return nil } do { let (data, _) = try await URLSession.shared.data(from: url) return try decoder.decode(type, from: data) } catch { return nil } } func fetchPosts() async -> [Post] { await fetch(type: Post.self, from: "https://jsonplaceholder.typicode.com/posts/") ?? [] } }
Create a ViewModel
class to manage the business logic
@MainActor class PostViewModel: ObservableObject { @Published var posts: [Post] = [] @Published var isLoading = false let service = PostService() func fetch() async { isLoading = true result = await service.fetchPosts() isLoading = false } }
Since published properties update the UI, any changes that are made to them need to be run on the main thread. By marking a class with the @MainActor
attribute, the Swift compiler will guarantee that all methods and properties on it are only ever called from the main actor.
Add a List
view in ContentView
to present the posts
struct ContentView: View { @StateObject var vm = PostViewModel() var body: some View { List(vm.posts) { post in Text(post.title) } .listStyle(.plain) .task { await vm.fetch() } } }
Fetching remote data in UIKit
The NetworkService
looks like this
class NetworkService { let session: URLSession init(session: URLSession = .shared) { self.session = session } func get<T: Decodable>(url: URL) async throws -> T { let urlRequest = URLRequest(url: url) let result: (data: Data, response: URLResponse) = try await self.session.data(for: urlRequest) let statusCode = (result.response as? HTTPURLResponse)?.statusCode guard let code = statusCode, (200..<300) ~= code else { throw NetworkError.serverError(statusCode: statusCode) } return try JSONDecoder().decode(T.self, from: result.data) } }
Let's define a manager that will do a network call
struct UserManager: AsyncAwaitUserService { let networkService: NetworkService init(networkService: NetworkService = NetworkService()) { self.networkService = networkService } func getUser(id: Int) async throws -> User { let url = URL(string: "https://www.server.com/user")! .appendingPathComponent("\(id)") return try await self.networkService.get(url: url) } }
We can use it with following snippet (somewhere in UIViewController
)
let um = UserManager() var userTask: Task<Void, Never>? override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) userTask = Task { do { let user = try await um.getUser(id: 1) updateUI(user) } catch { print("Request failed with error: \(error)") } } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) userTask?.cancel() }
Converting a completion block function to async/await
A continuation is an object that tracks a program’s state at a given point. The Swift concurrency model assigns each asynchronous unit of work a continuation instead of creating an entire thread for it. Concurrency model creates only as many threads as there are available CPU cores, and it switches between continuations instead of between threads, making it more efficient.
With await
your current code suspends execution and creates a continuation that represents the entire captured state at the point of suspension. Next step it hands the thread and system resources over to the central handler, which decides what to do next. When the awaited function completes, your original code resumes, as long as no higher priority tasks are pending.
Manually creating continuations allows you to migrate your existing code gradually to the new concurrency model.
There are two continuation API variants:
CheckedContinuation
. A mechanism to resume a suspended execution or throw an error. It provides runtime checks for correct usage and logs any misuse.UnsafeContinuation
. An alternative to CheckedContinuation
, but without the safety checks. Use this when performance is essential and you don’t need the extra safety.So, it's clear that async/await is the way that Apple wants us to develop concurrent code in Swift. But what if a framework we have to use still has a completion block-based interface? Swift 5.5 provides a simple way to convert these APIs to async await functions.
Let's take a completion block function for fetching a list of post, with the following signature
func fetchPosts(page: Int, completion: ([Post]) -> Void)
We use the withCheckedContinuation
function to transform it into an async
function with the following signature:
func fetchPostsAsync(page: Int) async -> [Post] { return try await withCheckedContinuation { continuation in self.fetchPosts(page: page) { result in continuation.resume(with: result) } } }
The withCheckedContinuation
function suspends the current task, and then it calls the passed callback with a CheckedContinuation
object.
Inside the callback, we call the completion block-based function, and when it finishes, we resume the execution of the task via the CheckedContinuation
that withCheckedContinuation
provided.
An important note here is that we must call continuation.resume()
exactly once in the withCheckedContinuation
block. If we forget to do it, our app would be blocked forever. If we do it twice, the app would crash.
Swift provides another function to deal with APIs that can return an error state. Let's say we have the following function:
func oldAPI(completion: (Result<[Post], Error>) -> Void)
We can use the withCheckedThrowingContinuation
function to transform it into a function with the following signature
func newAPI() async throws -> [Post]
The withCheckedThrowingContinuation
function follows the same pattern as the withCheckedContinuation
, as you can see from the following code
func newAPI() async throws -> [Post] { try await withCheckedThrowingContinuation { continuation in oldAPI { result in switch result { case .success(let value): continuation.resume(returning: value) case .failure(let error): continuation.resume(throwing: error) } } }
Grouping asynchronous calls
Have a look at following code
let value1 = try await model.getValue1() let value2 = try await model.getValue2()
Both calls are asynchronous and could happen at the same time. However, by explicitly marking them with await
, the call to getValue2()
doesn’t start until the call to getValue1()
completes.
Sometimes, you need to perform sequential asynchronous calls — like when you want to use data from the first call as a parameter of the second call.
Swift offers a special syntax that lets you group several asynchronous calls and await them all together.
do { async let value1 = try model.getValue1() async let value2 = try model.getValue2() let (value1Result, value2Result) = try await (value1, value2) } catch { errorMessage = error.localizedDescription }
An async let
binding allows you to create a local constant that’s similar to the concept of promises in other languages.
To get the binding results, you need to use await
. If the value is already available, you’ll get it immediately. Otherwise, your code will suspend at the await
until the result becomes available.
To group concurrent bindings and extract their values, you have two options:
async let
approach has one restriction, it can't run a variable number of tasks at the same time, we have to to await
the results.
A Task Group is a form of structured concurrency designed to provide a dynamic amount of concurrency. With it, we can launch multiple tasks, launch them in a group, and have them execute all at the same time.
There are a few task group behaviors that we need to be aware of:
To create a task group, we either call withTaskGroup
or withThrowingTaskGroup
, depending on whether we’d like to have the option to throw errors within our tasks.
Let’s create a simple image loader that asynchronously returns a dictionary of images keyed by the URLs that they were downloaded
func loadImages(from urls: [URL]) async throws -> [URL: UIImage] { try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in for url in urls { group.addTask{ let image = try await self.loadImage(from: url) return (url, image) } } var images = [URL: UIImage]() for try await (url, image) in group { images[url] = image } return images } }
All child tasks will run concurrently and we have no control over when they will finish running. In order to collect the result of each child task, we must loop through the task group. Notice we are using the await
keyword when looping to indicate that the for
loop might suspend while waiting for the child task to complete. Every time when a child task returns, the for
loop will iterate and update the images
dictionary.
Canceling an async task
Canceling unneeded tasks is essential for the concurrency model to work efficiently.
If you write your async code inside a .task{}
view modifier in SwiftUI, it will be responsible for automatically canceling your code when the view disappears. But the actions in some button aren’t in a .task{}
, so there’s nothing to cancel your async operations.
To fix this issue, you’ll manually cancel your task. Start by adding a new state property
@State var someTask: Task<Void, Error>?
In someTask
, you’ll store an asynchronous task that returns no result and could throw an error. Task
is a type like any other, so you can also store it in your view, model or any other scope. Task
doesn’t return anything if it’s successful, so success is Void
; likewise you return an Error
if there’s a failure.
Next, assign your task task to someTask
someTask = Task { ... code ... }
This stores the task in someTask
so you can access it later. Most importantly, it lets you cancel the task at will.
You’ll cancel the task when the user navigates back to the previous screen. Add following code in .onDisappear
or viewWillDisappear
.
// SwiftUI .onDisappear { someTask?.cancel() } // UIKit override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) someTask?.cancel() }
Canceling someTask
will also cancel all its child tasks — and all of their children, and so forth.