Task-based async/await in swift

Ali Akhtarzada
Mac O’Clock
Published in
9 min readJun 3, 2020

A proposal for async/await was first committed to draft in 2015, and the Concurrency Manifesto was put up by Lattner in 2017. While there were goals to get the ball rolling on async/await around Swift 5, the functionality is still not with us. However, there are a number of ways you can get some Swift-y async/await functionality in your apps, and Tasker was developed to do just that, and a lot more.

In this post we’ll go over asynchronous APIs in general and some of their issues, what the async/await model of concurrency gives us, some of the primitives that Apple provides, and how Tasker can work for your existing code, new code, and future code when swift actually gets async/await. My what a mouthful.

But before that, here’s a sample of the kind of code you can get from Tasker:

func fetchAllTheData() -> AnyTask<Data> {
AnyTask<Data> { done in
do {
let myData = try fetchMyData().await()
let yourData = try fetchYourData().await()
let everyonesData = try fetchEveronesData().await()
done(.success(myData + yourData + everyonesdata))
} catch {
done(.failure(error))
}
}
}

Then you can fetchAllTheData either asynchronously:

fetchAllTheData().async { result in
switch result {
case let .failure(error):
// 😭
case let .success(allData):
// 😱
}
}

Or synchronously:

let allData = try? fetchAllTheData().await()

Asynchronous APIs

There’s often a need to send off api requests, or encoding requests, or any other time-consuming work, and then do something after the results are in. Or do something only after a group of results are in. To handle these situations, we use various synchronisation primitives and asynchronous APIs with callbacks that tell us when the APIs are done. (Note: we also have the notion of delegates, which allows us to hook in to asynchronous results, but we won’t touch on that in this article).

The issues that come up constantly when trying to deal with the results of asynchronous calls are:

  1. The need to make another call only after the first one succeeds
  2. The need to send off multiple calls and handle them as a group
  3. Sharing data between calls without inadvertently overwriting memory and causing the end of days
  4. That moment of dread in your heart, and in every fibre of your being, when you realize you have a race condition

Ok, so there’s not much we can do about that last one there.

And this list doesn’t even begin to go in to the issues that arise while dealing with data inside an asynchronous function’s body, for example ensuring that variables are handled on the correct threads.

I’m done, call me?

Key to the asynchronous API is the “done”, or “completion” callback. We use this callback to let client code know that we are finished doing the requested work, and we either have a successful result or an error. This callback is named done or completion in a lot of code and typically looks like this:

func myAmzingAPI(theBestArgument: Int, completion: () -> Void) {
DispatchQueue.global(qos: .utility).async {
// Step 1: Do amazing work with the best argument
// step 2: ...
// Step 3: Profit!
completion()
}
}

The completion callback above is the simplest one you can have. It has no result, and it doesn’t propagate any errors. The only thing it does is tell client code: “Hey buddy, I’m done here. You may carry on inside this completion handler I called for you”.

Where art thou Result

While a 0-parameter completion callback is not exactly uncommon, you usually need to give back some data to client code, and tell them if there were any errors. After the blessing of Swift Evolution Proposal 0235, a Result type found its way into Swift 5's standard library. So now, most completion callbacks can (and arguably should) provide a Result in their completion callback, which can provide success and failure results.

func fetchCake(
applyChocolateFondant: Bool = true,
completion: (Result<Cake, Error>) -> Void
) {
DispatchQueue.global(qos: .utility).async {
if winning {
// ...
completion(.success(🍰))
} else {
// ...
completion(.failure(TheCakeIsALie))
}
}
}

Grand central dispatch

Apple provides us with the grandiosely named grand central dispatch library, or just GCD for short, to be able to deal with all the asynchronous programming in Swift. GCD allows you to take long runnings tasks on different threads, and it allows you to synchronize access to your data to avoid memory corruption.

While this library is impressive (to say the least) and decently straight forward to use, it has a number of problems that crop up when you start to get in to heavy development.

  1. Callback hell: The level of indentation quickly goes up the woozaa when you need to make a number of calls.
  2. Thread-safe programming: I’m sure there are a few books on this thing here. Race conditions in particular are a source of much comfort food binging.
  3. Cancelling tasks. While not impossible, it’s not trivial to implement with the addition of timeouts.

Enter async/await 🐉

One of the nicest solutions to the problem of callback hell is the async/await pattern. The concept is simple: allow the imperative execution of asynchronous code. I.e. instead of using callbacks to handle the return values when an asynchronous operation completes, you just return the a result from a normal function and the compiler does the rest for you.

So instead of this:

func api(completion: (Data?) -> Void) {
URLSession.shared.dataTask(with: url) { (data, _, _) in
completion(data)
}.resume()
}
func client() {
api { result1 in
api { result2 in {
DispatchQueue.main.async {
updateView(result1 + result2)
}
}
}
}

We get this:

func client() {
let result1 = api()
let result2 = api()
updateView(result1 + result2)
}

Now, while the syntax of the above would be very nice to have, there’re a number of obstacles that need to be solved. The language needs to know that the call to api() is an asynchronous one and has to put in various concurrency primitives to ensure the body of that function is executed in another context and the result is then returned in the client context (for more information on this check out coroutines) . And the second issue is that, as is common in Cocoa/iOS development, all your views need to be updated on the main thread, so updateView needs to tell the swift compiler that it needs to be executed on the main thread.

We don’t have that in Swift yet, and the current proposal for async await will need the keywords async and await placed in appropriate positions for the above to work. It doesn't really address the issue of having updateView know that it should be called on the main queue, and probably can't until implementation work on Swift's Ownership Manifesto reaches some acceptable level.

Anyway, the thing is that we can get decently close to the proposal in Swift today, but it will never be as nice without compiler support. So, Tasker was developed for a number of reasons, and one of them was the async/await pattern.

Turning your asynchronous calls into await functions

Now there’s going to be a lot of code out there that has already been written in the asynchronous style of APIs; they do work and call a completion callback. With Tasker, we can turn our asynchronous APIs in to Tasker await APIs.

Let’s say you have an api:

func api(done: @escaping (Int) -> Void) {
DispatchQueue.global(qos: .utility).async {
// Do some long running calculation
done(5)
}
}

It’s quite typical to see networking code, or code that performs long running tasks, that have a structure similar to the above. With Tasker you can get rid of callback hell using its await function wrapper. The only requirement that must be satisfied to use the await wrapper, is that the api you give it must have a completion callback as its first parameter. The completion callback can be any of the following forms:

  • () -> Void
  • (T) -> Void
  • (Result<T, Error>) -> Void

You’ll notice that the only difference is the parameter of the unnamed done callback.

So now with this primitive, you can create a number of asynchronous functions that have done callbacks and call them imperatively:

do {
let result1 = try await(block: api)
let result2 = try await(block: api)
DispatchQueue.main.async { updateView(result1 + result2) }
} catch { /* HANDLE YOUR ERRORS DAMMIT!! */ }

Do not call await on your main thread

You’re just going to have problems mmmkay? Make sure you wrap that 💩in an async call, which Tasker also conveniently provides for you:

async {
do {
let result1 = try await(block: api)
let result2 = try await(block: api)
DispatchQueue.main.async { updateView(result1 + result2) }
} catch { /* I believe we do not need to have this talk again */ }
}

Can I haz arguments?

Of course it’s not practical to assume that all your APIs will only have the done callback. There may be some other parameters as well. You have two options if this is the case. The first one, is that you can lamely wrap your calls:

func api(param: Int, done: () -> Void)
let result = await { done in api(param: param) { done() } }

The above will work because await's last parameter is a function that takes a done callback. So you call that callback in your api's done callback. Easy as pie no? No? Yeah ok that's kinda icky. So, Tasker provides a convenience currying function that can be used like:

let result = await(block: curry(api)(param))

A few of things about the the provided curry:

  1. You lose the named parameter.
  2. The completion callback has to be the the last parameter in your api for this to work (if your completion callback is not the last parameter then we have to have a heart-to-heart about API design 🤨).
  3. It only supports a limited number of parameters right now but can easily be extended.

Creating an async function with AnyTask

Tasker, as its name so eloquently hints at, operates on tasks . Every time you call async or await, a Task is created for you and executed until completion, error, or timeout. At the highest level of Tasker is the AnyTask. This abstracts away the details of the Task protocol and operates on closures.

The initializers for AnyTask look like this:

init(
timeout: DispatchTimeInterval? = nil,
execute: ((Result) -> Void) -> Void
)
init(
timeout: DispatchTimeInterval? = nil,
execute: (() -> T) -> Void
)

As you may have noticed, they bare some resemblance to the completion callbacks that await expects its arguments to have. So you can create an AnyTask in the same vein. And in doing so, create Tasker's version of an async function which you can call await on directly:

func myLudicrousAPI() -> AnyTask<Video> {
AnyTask<Video> { done in
// Take your time, process that data. Yeah.
done(.success(video))
}
}

And we already saw at the beginning of this article how you could call myLudicrousAPI.

The Future

There’s a lot that we have not covered about Tasker in this post, namely how task handles work, how to create custom Tasks, concepts of Reactors and Interceptors, and some pretty useful utilities such as the ability to process multiple Tasks in parallel, and more! They'll hopefully show up in a followup post. But one thing to note is that Tasker is also looking at the future of async/await in Swift so that transitioning from Tasker to a probable design of async/await in Swift should be easy, unless things change drastically.

As of June 2020, there is no specific timeline for async/await in Swift, and from the conversations on the forums, it doesn’t seem to be around the corner just yet. One thing that Swift devs have mentioned is that the Ownership Manifesto needs to be implemented first. But there’s a good chance the syntactical design will be similar to javascript’s or Rust’s model. I.e. something like:

func myAmazingApi() async throws -> Int {
// Do work
return 3
}
func client() async throws {
let result = try await myAmazingApi()
}

If you were to convert that in to Tasker-speak, it'd look like this:

func myAmazingApi -> AnyTask<Int> {
AnyTask<Int> {
// Do work
return 3
}
}
func client() throws {
let result = try myAmazingApi().await()
}

Not far off. And this is ignoring error handling differences. Tasker currently assumes any async/await function can throw, but this is obviously not true and needs to be fixed for Tasker's future.

We also did not touch upon the core of Tasker in this article, which is the TaskManager. That will be left to another post as well.

You can find this article and maybe more on my notion blog, where code is syntax highlighted, and discussions can happen everywhere 😎

Tasker is available on github under the MIT license via cocoa pods, swift package manager, and carthage.

Thanks to Tor Arvid Lund, Kevin Simons, and Thomas Torp for going over some drafts and providing feedback

Ali Akhtarzada is currently CTOing at a travel tech startup aiming to make city discovery for locals and tourists the shizzle. He also thinks giving himself credit in the 3rd person is odd.

--

--

Ali Akhtarzada
Mac O’Clock

Teching around with startups, non-startups, and all the weirdness in between. Coding, producting, failing, etc. Currently “CTO”ing at a travel tech startup.