Concurrency with Swift: Async/Await
Concurrency is not something we haven’t known, but in Swift 5.5 this is built-in.
Wait…Why ?? We have been doing concurrent programming for so long and our apps were pretty good and responsive.
Yes, we were able to achieve concurrency in swift by using frameworks like Grand Central Dispatch (GCD) and Operations. They were handy and we made the best use of them to overcome most possible scenarios where we need an async approach similar to downloading images off the main thread and setting the image in image view back on the main thread, persisting data to the local file system, concurrent use of DB (SQLite, CoreData). In situations where some level of dependency was required we may have used Operations Dependency, DispatchSemaphore or DispatchTasks to manage those scenarios.
So, Why do we need another API when we have solved almost all our problems with GCD and Operations?
There are two takes on why we needed another robust threading model.
- Unstructured, Yes though GCD and Operations were good friends in need but had their pros and cons. Like GCD lacks Structured Flow and both GCD and Operations are unable to enforce compiler-level concurrency checks. So, using Swift’s language-level concurrency support for concurrency means Swift can help you catch problems at compile time.
- Swift Concurrency is not built on GCD, unlike Operations, and is designed to be more efficient than GCD by avoiding problems like Thread Explosion.
When we talk about concurrency let me emphasize that we talk about asynchronous and parallel code. To learn more about asynchronous and parallel code, Please check out Swift’s official document.
Let’s now understand the modern swift concurrency, It has 3 significant features:
- Async/await
- Structured Concurrency
- async let
- Tasks
- Tasks group
- Actors
Async/await and Structured concurrency is mostly used in conjunction to remove the call back type (closure-based) asynchronous programming whereas Actors help to avoid data races.
Async/Await:
To understand Async/Await it is a must to understand the context of why it is a need in today’s programming. In the early days of programming, it was all about a set of instructions that made our programs jump all around but with programming evolution, we don’t see that today and we managed to have a set paradigm of structured control flow as you see with if .. else.
Also, with Structured Programming, we get static scoping which means the lifetime of a variable defined in a block will end when leaving the block. So, When our program executes sequentially, it makes our life easier. But with a more evolved CPU architecture, we needed to make our programs asynchronous.
We’ll talk more about structured concurrency and Actors in the later part of this series. Let’s focus on learning Async/await asynchronous functions over closure call-back hell.
Functions: synchronous vs. asynchronous
Synchronous:
Here we are calling someHeavySynchronousTask from the main thread so this function runs on the main thread only.
Asynchronous:
Similar task with an asynchronous call type is a better option to run it asynchronously. We used GCD to dispatch the task on a global queue which uses a background thread, and the main thread was unblocked immediately.
A rule to remember: Sync code blocks threads, async code doesn’t.
You may have written code similar to this many times. For eg. URLSession’s dataTask asynchronous method with closure argument.
This is great but it has a bug as it doesn’t complete the task even though my function definition is exactly what it has to be.
Any guesses !!
If you guessed it right, it is we missed writing the completionHandler at the appropriate place.
When writing async code with completion handlers you unblock threads but it’s easy to not call your completion handlers. For example when you use a guard let and only return in the else clause. Swift can’t enforce this in the compiler which can lead to subtle bugs.
The shortcoming of using GCD/Operations for Concurrent programming.
- No check for concurrency constraints at compile time.
- We can not use try/catch error handling.
- Awkward order of execution. We will end up with a so-called pyramid of doom.
- Retain cycles need to be avoided using weak references.
- Data Races.
- Thread explosions are common.
Let’s improve the above asynchronous function with Swift 5.5.
We’ll start by removing the completionHandler and returning the [Int]. Just as a synchronous function. Now we add the async keyword just before the → to make this function async type.
You may have noticed that the body of func has also changed a little.
Let’s visit the changes:
- Function definition was changed and we removed the completion handler. Instead added the keyword async to denote the function as asynchronous and awaitable.
- Here we are using Task instead of DispatchQueue.Global to start the operation asynchronously and use await to wait for the operation to finish. This is our suspension point and at this point our func waits for the async task to finish successfully or with error (if throwable task).
“The await keyword signals to the developer that the state of your app can change dramatically while a function is suspended and the thread that the function is on is free to do other work. Swift does not guarantee which thread it will resume your function on. This is an implementation detail that you shouldn’t care about.”
“A Task is an asynchronous unit that allows us to bridge the async function call from a synchronous context. Also Within the context of a task, code can be suspended and run asynchronously.”
- Once the task operation is complete and it is ready with the result. Our function will resume from the suspension point. Here it is to notice that OS may resume on any arbitrary thread and not be confirmed to resume on the calling thread.
Do notice the Task’s value computed property is an async throwable property. We’ll talk about async throwable in a while.
That is all it took to refactor our closure-based asynchronous function to Swift 5.5 concurrency.
Error… Huh!! But why ?
Yes, the function call site is complaining about making calls to asynchronous functions from a synchronous context.
This is solved by creating a bridge between synchronous and asynchronous context and is done by creating a Task or by making the call site function async. Doing later will just move the error to the call site for the current function. So, We’ll eventually end up creating the asynchronous context.
So, our call site is from within the Task that creates an asynchronous context.
The logs on the console shows that “someHeavyAsynchronousTask” async func starts on Thread #6 where as Thread #7 perform the heavy task and someHeavyAsynchronousTask is suspended by “await”, Once the heavy task is done and system resume the function someHeavyAsynchronousTask, it is on thread #9.
As said earlier – “System may or may not suspend the function”. So, we should not get confused if you see the system resuming back on the same calling thread.
That was the most straightforward application of async/await and Task. We’ll talk about Tasks in more detail later in the series.
Let us now see the handling error with async/await.
Syntactically, the async and await keywords are used similarly to throws and try. So similar to the simple throwing sync function, an async func can throw as well.
Here we see how the error has propagated over function call stack.
Also, don’t miss how asyncAwaitVM object was accessed without explicit self. Better to say implicit capturing, that happens whenever we create a Task, or use await to wait for the result of an asynchronous call. Any object used within a Task will automatically be retained until that task has finished (or failed), including self whenever we’re referencing any of its members.
In the start we talked about Async/Await help to clear the programming hassle Pyramid of doom and write our code in structured flow.
Let’s see with an example.
The order of execution is messed up. 1→ 5 → 2 → 6 → 3 → 7 → 4
Let’s get it right. Doing so with Async/Await will require an async version of each function.
We can easily do it using the refactor support from xcode.
Of the 3 refactor options we may choose the option of our need. I chose the Add Async Alternative and my function was refactored
As you can see this refactoring creates an async function with the same name and its body is returning with an awaitable continuation closure. The withCheckedContinuation() function creates a new continuation that can run whatever code you want, then call resume(returning:) to send a value back whenever you’re ready – even if that’s part of a completion handler closure. It is to be noted that with this type of refactoring we need to be careful to call tha resume only once.
I purposely opted to use refactoring using continuations to demonstrate that it will be goto option for APIs we don’t have an actual implementation source to refactor. But as we have access to HeavyOperationApi class source, I implemented the async version of the required functions manually.
Learn more about continuations here.
func multipleHeavyAsynchronousCallBackTask(start: Int, end: Int, completionHandler: @escaping ([Int]) -> Void)
This function is refactored to
With the above code refactor we fixed the Pyramid of doom.
Effectful Read-only Properties (get async throws):
As I mentioned before Task’s value property is an async throwable read-only property.
To demonstrate get async throws property, I added a custom reducedValue property on Array<Int> and used it in above function we just refactored. It is called just like async function using try await.
Check out this SE-0310 for more on async get properties.
Though i wanted to talk about async-let binding in this part but as it contribute more to Structured concurrency. I am keeping it to my next article Concurrency with swift: Structured Concurrency
That concludes async/await.
let’s recall a few rules and benefits of using Swift 5.5 concurrency async/await.
Rules of async func:
- Can not simply call async func similar to sync func. We need an asynchronous context to initiate the async call.
- Make the call site an async func, method or property.
- Start with Unstructured Task, a task with no parent task. (We’ll talk more about Tasks in the later part of this series.)
- Task.init(priority:operation) creates an unstructured task on the current actor. (The Task we saw in the above example.)
- Task.detached(priority:operation) creates an unstructured task that is not a part of the current actor.
- Static main method of struct, class or enumeration marked with @main.
- Async functions can call other async functions, but they can also call regular synchronous functions if they need to.
- If you have async and synchronous functions that can be called in the same way, Swift will prefer whichever one matches your current context – if the call site is currently async then Swift will call the async function, otherwise it will call the synchronous function.
Benefits:
- Simple flow of execution. No more back and forth between closures.
- No more missing completion handlers.
- No memory leak due to retain cycle formation.
- Structured error handling.
- Performance boosted due to much optimised thread handling (Cooperative threading model).
Happy learning !