Async Let Bindings: a Gotcha and a Closer Look
Swift 5.5 introduced a powerful set of concurrency tools to deal with asynchronous tasks, eliminating the complexity of callbacks and queues.
Besides Task and TaskGroup for managing child tasks, the async let
syntax allows you to start concurrent tasks and bind their results to variables, following structured concurrency principles.
Yet, be cautious when writing async let
bindings. Consider these two declarations below.
async let x = taskX() // returns an Int
async let y = taskY() // retunns a String
let result = await (x, y)
async let (x, y) = (taskX(), taskY())
let result = await (x, y)
At first glance, they seem equivalent. Both deliver result
as a tuple of values from taskX()
and taskY()
. But are they truly the same? No, they’re NOT.
The difference lies in concurrency. In the first example, taskX()
and taskY()
run concurrently. That results in a total of 1s if each task takes 1s. Meanwhile, the two tasks in the second run sequentially, taking 2s in total. What a performance gap for such a subtle syntax change!
Why the Difference?
The root cause is how Swift interprets the right-hand side of a binding. In the second example, (taskX(), taskY())
is treated as a single asynchronous task. This task consists of two asynchronous calls which are performed one-by-one according to the evaluation order, from left to right.
I guess this is by design rather than an implementation limitation. Why? Let’s reverse-think it. If (taskX(), taskY())
spawns concurrent tasks, how would you express a tuple of sequential tasks? And what about literal arrays like [taskX(), taskY()]
- should they also imply concurrency? These not only clash with Swift’s left-to-right evaluation order but also lead to error-prone code.
If you want a shorthand for concurrent tuples, you may need a helper function like this:
func asyncTuple<U, V>(_ first: @autoclosure () async -> U, _ second: @autoclosure () async -> V) async -> (U, V) {
async let firstValue = first(), secondValue = second()
return await (firstValue, secondValue)
}
async let result = asyncTuple(taskX(), taskY())
async let (x, y) = asyncTuple(taskX(), taskY())
...
A Closer Look
Async let vs. Task
It’s important to know why async let
is needed when we already have Task
. In fact, the concurrent tuple in the previous section can be implemented this way:
let tx = Task { await taskX() }
let ty = Task { await taskY() }
let result = await (tx.value, ty.value)
Well, this touches on the topic of structured concurrency which we will not go into detail about in this post. While Task
works, it’s unstructured in some cases. Tasks can outlive their scope (eg. with Task.detached
) which breaks the rules of structured concurrency. Meanwhile, async let
ensures execution within a defined scope (eg. within the function), allowing the compiler to enforce compile-time checks for concurrency practices.
Async let Implementation
Syntactically, let
and async let
look similar, but their implementations differ. A regular let
binding has two key phases: instantiation and destruction. An async let
has one more: when the value is awaited, or ready. In Swift’s concurrency model, this can be roughly understood as a suspension point where the current execution gives up its thread for others to work (until gaining it back - alongside the result).
Consider this simple code and look at its SIL (Swift Intermediate Language) to understand the lifetime of an async let
variable.
async let x = taskX() // async -> Int
await x
xcrun swiftc -emit-silgen <main.swift> | xcrun swift demangle
// allocate result buffer
// (1) start async task
%2 = alloc_stack $Int
...
%8 = builtin "startAsyncLetWithLocalBuffer"<Int>...
...
// (2) get value
%9 = function_ref @swift_asyncLet_get ...
// (2.1) invoke swift_asyncLet_get
%10 = apply %9(%8, %3)
...
// (2.2) read value from result buffer
%12 = pointer_to_address %3
%13 = load [trivial] %12 : $*Int
...
// (3) end lifetime
%22 = builtin "endAsyncLetLifetime"
dealloc_stack %2 : $*Int
Here’s what happens:
(1) Start (startAsyncLetWithLocalBuffer
)
Allocates a buffer for the result, and kicks off the task.
(2) Await (swift_asyncLet_get
)
Suspends execution, waits for completion, then reads the result from the buffer.
Note: There’s a cache in swift_asyncLet_get logic to prevent re-running the task if its result is already populated. This allows us to await
the result multiple times.
(3) End: Frees up task and its buffers when the scope exits.
With multiple async let
bindings, each has its own startAsyncLetWithLocalBuffer
. The order in the SIL as below indicates the concurrent executions.
...
// start taskX
%8 = builtin "startAsyncLetWithLocalBuffer"<Int>...
// start taskY
%15 = builtin "startAsyncLetWithLocalBuffer"<String>...
...
// get result of taskX
%16 = function_ref @swift_asyncLet_get ...
// get result of taskY
%21 = function_ref @swift_asyncLet_get ...
...
Binding With Patterns: async let
vs. let
Now, let’s revisit the tuple case:
async let (x, y) = (taskX(), taskY())
await (x, y)
The SIL shows only one startAsyncLetWithLocalBuffer<Int, String>
. This confirms the fact that Swift evaluates the tuple as a whole, rather than splitting into two bindings for x
and y
.
// initialize tuple
%2 = alloc_stack $(Int, String)
...
// (1) start task
%8 = builtin "startAsyncLetWithLocalBuffer"<(Int, String)>...
...
// (2.1) get 1st result of the tuple
%9 = function_ref @swift_asyncLet_get ...
%10 = apply %9(%8, %3)
...
%13 = tuple_element_addr %12 : $*(Int, String), 0
%14 = load [trivial] %13 : $*Int
...
// (2.2) get 2nd result of the tuple
%15 = function_ref @swift_asyncLet_get ...
%16 = apply %15(%8, %3)
...
%19 = tuple_element_addr %18 : $*(Int, String), 1
%20 = load [trivial] %19 : $*String
But is that the case for let
bindings? Let’s find out.
When binding let (x, y) = ts
, the tuple is decomposed into two.
**(%6, %7) = destructure_tuple %4 : $(Int, String)**
debug_value %6 : $Int, let, name "x"
debug_value %7 : $Int, let, name "y"
However, in cases of exact semantic matches like let (x, y) = (syncTaskX(), syncTaskY())
, the SIL reveals that the compiler directly assign syncTaskX()
and syncTaskY()
to x
and y
, skipping the unnecessary tuple creation and decomposition.
// assign syncTaskX() directly to x
%1 = apply %0() : $@convention(thin) () -> Int
debug_value %1 : $Int, let, name "x"
...
// assign syncTaskY() directly to y
%4 = apply %3() : $@convention(thin) () -> Int
debug_value %4 : $Int, let, name "y"
…
As you can see, despite the syntactic similarity, let
and async let
may bind values to variables differently under the hood.
Be mindful when writing your code. Subtle differences can trip you up.
Like what you’re reading? Buy me a coffee and keep me going!
Subscribe to this substack
to stay updated with the latest content