Swift async/await – how it works under the hood

Swift 5.5 introduced a new concurrency model for Swift. It is async/await. It helps us create concurrency application easier. But I am very curious about how it works.
I think understanding what’s going on under the hood will help us clear our mind about how it works.

1. Example

To declare a sync function is very simple. Just need to add async to function declearation after )
We have a simple example here. I will use it to explain how it works in this article.

In the example, at line 2, we have await. It tell swift that waiting for getSwiftVersion return result before printSwiftVersion should be called.
It will pause the execution and waiting for result. So is it block the thread or not? Let’s move to next part

2. Why await not block thread

Each time Swift calls await, Swift will create a resume point. It allows function to continue working when async functions return results.

LLVM compiler engineers also created a new technique called tail call. It allow Swift to create a resume point when we call await.
So, Swift compiler will separate a function into multiple small part of function when we call await. It will call it as fragments
So how it works:

The image describes the execution flow of caller in the example.

3. AsyncContext

In a normal function, we have a stack frame to manage all stack variables on a function.
But in an async function, a stack frame is not enough. You can imagine how a fragment can access a variable from another part while technically they are different functions. So we have a way to do it: we define and use AsyncContext.
Not only manage variables, it also contains a pointer to parent context and a resume function.
The resume function is an entry for next fragment to get back to work after async call is completed.

4. Control flow

When await happens, there are 4 steps: pre-call, body execute, and pre-resume, resume execution. Sometimes, we have nothing to do in resume execution, and then resume execution step may be do nothing (Like our example, pre-resume will act like resume also)
So what happens on each step?

  • Pre-call step:
    • Prepare function arguments
    • Create AsynContext by swift_task_alloc
    • Assign parent context and resume function. Resume function pointing to the next part of parent function. In this case is a part of caller
    • Copy argrument into AsyncContext
    • Call swift_task_switch to switch to next step
  • Body execution step:
    • Execute the body function code
    • Call resume function pointer
  • Pre-resume step:
    • Dealloc task by calling swift_task_dealloc to destroy AsyncContext
    • Saving return value
    • Call swift_switch_task to switch to resume step
  • Resume execution step:
    • Handle result, call another async function or something like that.

This picture will describe control flow of caller function in the example

5. How async/await access variable across suspended fragments

AsyncContext will act like a stack frame, it contains all sharing variables between suspention points. Sharing variable should be like flow

  • Step 1: On the resume point 1 of caller Swift will get the version variable and input into printSwiftVersion pre-call step.
  • Step 2: on printSwiftVersion pre-call, swift will copy version variable into its AsyncContext
  • Step 3: on printSwiftVersion async implement, swift will get variable from AsyncContext to call print("Version: \(number)")

6. Conclusion

Eventually, we understand how async await creates a resume point and how the function returns to work after await happens.
A lot of things related to new Swift concurrency, like schedulers and cooperative threads will be explained in other articles.

References:

Leave a Reply