@kavon and myself made some time to discuss the concerns and tradeoffs in depth and arrived at the following design that we're quite happy with and would like to put forward for consideration.
We both have spent considerable time working with async let
and, personally, feel like this strikes the right balance between between the "always force await" and "never force await" extremes that this proposal has explored so far (initially "always" and now "never" forcing awaits on async let bindings).
We think there are two points of tension in this proposal with code like this:
let g: () async throws -> T = ...
func f() {
async let x: T = g()
}
The problems in the above are:
- A throwing initializer expression for the async-let does not throw during the binding, but during the use of the bound variable.
- Writing
try
around that expression is confusing since it wrongly implies a do/catch around it would have a chance to catch an error but it never would. - Not stating anything about the possibility of thrown errors is also undesirable as it makes it hard to realize detailed error information might be available, but has been silently ignored.
- Writing
- Implicit cancellation in the example above means that the task is essentially created and then immediately cancelled, which could result in hard to notice, learn and remember "why isn't my code doing anything" situations.
- We want to discourage the use of fire-and-forget async-let bindings, because they are not forgotten. This immediate cancellation applies whichever way the rule about when cancellation happens: when leaving the scope in which its lexically defined, or the point of last use.
Proposal: disallow un-used async let
declarations
There is one simple rule that can solve both of these problems: all async-let bindings must be used at least once. This rule is equivalent to "unused variable" detection in present day Swift, but should be escalated as an error for async let
bindings.
A consequence of this is that async let _ = ...
bindings should be disallowed. This is good, because rarely would such binding achieve the expected effect: such async let would immediately cancel the resulting task, yet the result of it would always be awaited for implicitly at the end of the scope in which it was declared. This is not a short-hand for fire-and-forget, while it might look like it is! The proper way for fire-and-forget operations is to express them using an un-structured task, like so: Task { await fire() }
.
How does this rule solve the above concerns? It would result in code like this:
func f() {
async let x: T = g()
try? await x
}
or
func f(maybe: Bool) {
async let x: T = g()
if maybe {
try? await x
}
}
Requiring at least one use has these benefits:
- It exposes to the programmer the fact that the initializer expression throws, but only at its use site. Thus, the
try
only appears at the location where the error may be actually thrown. - The programmer expresses that there exists at least one code path in which the task is not cancelled. Of course, this doesn't guarantee that the explicit await of that task will ever be dynamically reached.
- It discourages the use of async-let for fire-and-forget tasks, since you cannot forget about it. Thus, in the example above, it becomes apparent that the await is happening immediately after the async-let, so the async-let is not needed at all.
This rule has one downside that we can think of: a Void-returning async-let requires you to await a void value. If many async-lets are in the same scope, it can be annoying to do this. But, the visual overhead would be at most one statement at the end of the scope that looks like this:
{
async let a = ...
async let b = ...
// ...
async let n = ...
// some other useful work
guard cond else {
// no awaits needed here,
// and we still get cancellation!
return
}
// even more useful work
try await (a, b, ..., n) // <- one-line overhead
}
On the other hand, now the programmer has no chance of accidentially allowing these tasks to be cancelled. We also think that Void
async-lets may be somewhat of a bad pattern and these may often better served with fire-and-forget Task{}
or explicit TaskGroup
operations, after all, let
declarations should be optimized for carrying actual values and not purely for scheduling of Void
operations.
-- Kavon & Konrad