How to use continuations to convert completion handlers into async functions
How to use continuations to convert completion handlers into async functions êŽë š
Updated for Xcode 15
Older Swift code uses completion handlers for notifying us when some work has completed, and sooner or later youâre going to have to use it from an async
function â either because youâre using a library someone else created, or because itâs one of your own functions but updating it to async would take a lot of work.
Swift uses continuations to solve this problem, allowing us to create a bridge between older functions with completion handlers and newer async code.
To demonstrate this problem, hereâs some code that attempts to fetch some JSON from a web server, decode it into an array of Message
structs, then send it back using a completion handler:
struct Message: Decodable, Identifiable {
let id: Int
let from: String
let message: String
}
func fetchMessages(completion: @escaping ([Message]) -> Void) {
let url = URL(string: "https://hws.dev/user-messages.json")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
if let messages = try? JSONDecoder().decode([Message].self, from: data) {
completion(messages)
return
}
}
completion([])
}.resume()
}
Although the dataTask(with:)
method does run our code on its own thread, this is not an async function in the sense of Swiftâs async/await feature, which means itâs going to be messy to integrate into other code that does use modern async Swift.
To fix this, Swift provides us with continuations, which are special objects we pass into the completion handlers as captured values. Once the completion handler fires, we can either return the finished value, throw an error, or send back a Result
that can be handled elsewhere.
In the case of fetchMessages()
, we want to write a new async function that calls the original, and in its completion handler weâll return whatever value was sent back:
struct Message: Decodable, Identifiable {
let id: Int
let from: String
let message: String
}
func fetchMessages(completion: @escaping ([Message]) -> Void) {
let url = URL(string: "https://hws.dev/user-messages.json")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
if let messages = try? JSONDecoder().decode([Message].self, from: data) {
completion(messages)
return
}
}
completion([])
}.resume()
}
func fetchMessages() async -> [Message] {
await withCheckedContinuation { continuation in
fetchMessages { messages in
continuation.resume(returning: messages)
}
}
}
let messages = await fetchMessages()
print("Downloaded \(messages.count) messages.")
As you can see, starting a continuation is done using the withCheckedContinuation()
function, which passes into itself the continuation we need to work with. Itâs called a âcheckedâ continuation because Swift checks that weâre using the continuation correctly, which means abiding by one very simple, very important rule:
**Your continuation must be resumed exactly once. Not zero times, and not twice or more times â exactly once.how-to-create-continuations-that-can-throw-errors
If you call the checked continuation twice or more, Swift will cause your program to halt â it will just crash. I realize this sounds bad, but when the alternative is to have some bizarre, unpredictable behavior, crashing doesnât sound so bad.
On the other hand, if you fail to resume the continuation at all, Swift will print out a large warning in your debug log similar to this: âSWIFT TASK CONTINUATION MISUSE: fetchMessages() leaked its continuation!â This is because youâre leaving the task suspended, causing any resources itâs using to be held indefinitely.
You might think these are easy mistakes to avoid, but in practice they can occur in all sorts of places if you arenât careful.
For example, in our original fetchMessages()
method we used this:
struct Message: Decodable, Identifiable {
let id: Int
let from: String
let message: String
}
func fetchMessages(completion: @escaping ([Message]) -> Void) {
let url = URL(string: "https://hws.dev/user-messages.json")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data {
if let messages = try? JSONDecoder().decode([Message].self, from: data) {
completion(messages)
return
}
}
completion([])
}.resume()
}
func fetchMessages() async -> [Message] {
await withCheckedContinuation { continuation in
fetchMessages { messages in
continuation.resume(returning: messages)
}
}
}
let messages = await fetchMessages()
print("Downloaded \(messages.count) messages.")
That checks for data coming back, and checks that it can be decoded correctly, before completing and returning, but if either of those two checks fail then the completion handler is called with an empty array â no matter what happens, the completion handler gets called.
But what if we had written something different? See if you can spot the problem with this alternative:
if let data = data {
if let messages = try? JSONDecoder().decode([Message].self, from: data) {
completion(messages)
}
} else {
completion([])
}
That attempts to decode the JSON into a Message
array and send back the result using the completion handler, or send back an empty array if nothing came back from the server. However, it has a mistake that will cause problems with continuations: if some valid data comes back but canât be decoded into an array of messages, the completion handler will never be called and our continuation will be leaked.
These two code samples are fairly similar, which shows how important it is to be careful with your continuations. However, if you have checked your code carefully and youâre sure it is correct, you can if you want replace the withCheckedContinuation()
function with a call to withUnsafeContinuation()
, which works exactly the same way but doesnât add the runtime cost of checking youâve used the continuation correctly.
I say you can do this if you want, but Iâm dubious about the benefit. Itâs easy to say âI know my code is safe, go for it!â but Iâd be wary about moving across to unsafe code unless you had profiled your code using Instruments and were quite sure Swiftâs extra checks were causing a performance problem.