If we were to accept that async let requires a decorator to indicate that the expression might throw, we'd expect to require the following for non-async code:
func f() throws -> Int { 0 }
func g() {
let f_ref = throws f <-- assigning a function ref to be invoked later
...
let x = try f_ref()
}
But, the reality is that we expect the documentation about throwing to be on the function declaration, not on code which assigns references or invokes the function.
The same should hold true for async let. To the extent that we can compare fundamentally different situations, throws is still on the function declaration. The difference between synchronous code and async let is now that the invocation of the method and the assignment (or use) of the return value is now separated both in time and place. So, while it makes sense to indicate that an exception may be caught by requiring try, there seems to be no room for requiring throws.
I don't think so. You could make the argument that it should be a different term, but I do think it's harder to make the argument that there shouldn't be some signal that there's something special about the assignment. Your argument would have us omit try in regular code.
[edit]
After thinking a bit longer, if async is left out, the implication would be that an await is required. We need either a signal that the current task should suspend until the value is available, or that it should create a new task which may run concurrently, for which the value will be awaited later.
We could infer intent by a lack of signal, but that is not the Swift way. Even statements in throwing functions need try.
I think I understand your point here: whether an async let variable needs an await or try await is part of the variable itself, just like the type is. My mental model for this is that variables can have an effect as well as a type. Swift's precedent is that the types of local variables are usually inferred, so any effects on them should also be. However, as is the case for the type of a variable, I feel like it would still be helpful (consistent and to aid learning) to have some way to mark these effects explicitly - similarly to how you can with the type of the variable. Not least because of how this is revealed to the user:
e.g. if I do
func foo() async throws Int { ... }
async let bar = foo()
and I option-click on bar to see the type, what should be shown? Is it async let bar: Int? That doesn't seem very satisfactory, because it doesn't show that I will need to use try await when I later want to get the value from it. Another possibility would be async let bar: async throws Int, but that uses async with 2 different meanings.
I'm therefore moving towards thinking that async let should actually use a different word to avoid this confusion. spawn was used in previous iterations of the Structure Concurrency proposal, so that would be one option. I think having it before the let makes sense - in the same way that we have if let, guard let that do branching. This is doing concurrent branching.
I think itās helpful to be explicit that thereās a basic asymmetry between the async and throws effects, as they related to a stored property (that is, to the async let syntax thatās being proposed).
No one is proposing a pure throws let syntax:
throws let x = throwingFunction()
If that meant anything, it would presumably mean that throwingFunction() isnāt called until the point where x is first referenced, where it would need a try x to indicate the possible control transfer due to an error. However, we donāt need to do that (for a stored property), because we could and would simply write:
let x = try throwingFunction()
at that point of first reference.
(Itās important that weāre talking about a stored property. For a computed property, we do have get throws { ā¦ }, though in that case weād need a try x at every reference to x.)
Thus, faced with this:
let x = someFunction()
the compiler knows without being told that x has no throws effect, regardless of whether someFunction has the effect.
However, it does not know whether x should or shouldn't have the async effect, because thatās not determined by someFunction. The intention could have been for x to have the async effect, or the intention could have been that someFunction should have been awaited.
Thus, in hypothetical syntax like this:
async throws let x = throwingAsyncFunction()
the async keyword has semantic significance, choosing between two meanings, while the throws keyword only has pedagogical significance, and is neither semantically nor syntactically necessary.
The question here is whether the pedagogic significance is important enough to clutter up the syntax. Personally, I donāt think so.
If there is an argument for this, it isnāt that throws should be there because async is there.
We thought about the throws a bit more with @kavon as well because we're looking into polishing those semantics and we agree that the async throws ends up not pulling its weight.
We also spent some time revising our proposed solution of "must await at-least once" (from SE-0317: Async Let - #30 by ktoso ) that we wanted to share with the thread. The original idea seems to have gotten quite some likes (if that means anything, maybe a little bit) and John seemed on board as well.
We ended iterated over the idea a bit more and arrived at the following:
we should ban async let _ = hello()
this includes any pattern binding where all bindings are _, i.e. async let (a, _) is ok, but async let (_, _) is illegal
there really is no reason to encourage this. It does not do what it might seem at first; it is not a way for a "fire and forget"
the created task would be immediately cancelled, leading to much potential confusion
currently, for fire and forget, the more verbose Task { await hello() } must be used and async let _ is not equivalent to that because it will wait for the child task implicitly anyway
we can keep the async let x = ... being unused as a warning, however it must be a tailored warning specific to the fact that it's an async let, and not just some arbitrary variable.
Specifically, presently an unused async let is just treated like a variable:
// today, just treated like a normal value
warning: initialization of immutable value 'x' was never used;
consider replacing with assignment to '_' or removing it
async let x = work()
~~~~^
We should improve the message to be:
warning: initialization of async value 'x' was never used and will be cancelled immediately;
use 'await x' to prevent the task from being cancelled immediately,
or create an un-structured task with 'Task { await work() }'
async let x = work()
~~~~~~~~~~^
warning: initialization of async value 'x' was never used and will be cancelled immediately;
use 'await x' to prevent the task from being cancelled immediately,
or create an un-structured task with 'Task { await work() }'
async let x = boom()
~~~~~~~~~~^
We can word-smith the specific warning message, but it is important that it explains that the task is immediately cancelled, and the two ways to solve this.
This seems to strike the right balance:
developers are informed that the task gets cancelled
the very bad ideaā¢ of _ assigning is forbidden, we could offer a helpful message there as for why it is so as well
developers who use warnings-as-errors would get the expected behavior here and be forced into awaiting explicitly at-least-once which is good
Itās not as simple as this, because the language contains both variants. Variable initialization just uses an expression with no braces. And this seems perfectly normal to everyone because let i = { 1 } looks silly, just as if foo { bar }() does. But sometimes you do want to initialize a let with multiple statements, and in that less frequent case people use the { }() idiom (which is not an anti-pattern, itās a perfectly reasonable form when initializing variables).
The question is, which should be used for async let? I see two arguments for no braces:
async let i = is very similar to let i = so should be like it i.e. no braces
if async let = that requires multiple statements is going to be fairly rare (just like initializing variables with multiple statements is) then the braces become clutter. In those cases where multiple statements are needed, { }() can be used just like in any variable initialization.
It seems to me that there are two conflicting models of what async let means, and not everyone is using one or the other consistently.
The async in async let indicates that the let binding carries an async effect, and possibly a throws effect, that must be handled before accessing its value. The compiler is able to insert an asynchronous, and typically concurrent, task to compute the value because there is an async effect.
The async in async let is a keyword indicating that an asynchronous, and typically concurrent, task will be created to compute the value of the let binding. The need to resolve async and possibly throws effects at the first use is a consequence of this task.
In the first model, it is reasonable and logically coherent to expect async throws let and even throws let to be meaningful, and may be confusing that this isnāt required or even allowed. If this is your model when learning about the feature, the apparent inconsistency is likely to be confusing and frustrating.
In the second model, the name async doesnāt make any sense. The key feature of the child task is not asynchrony, itās concurrency; if asynchrony is the only salient feature (e.g. because the value must be computed on the same global actor), there is no benefit over let x = await ... except in cases where you can demonstrably benefit from interleaving.
If we donāt want general effect-decoration of lets, a different keyword should be used. (TaskGroup.async is also bad.)
That's not unstructured is it? I thought that was the syntax for creating a new child task (lifetime guaranteed shorter than parent task) and you had to use Task.detached or asyncDetached or whatever the spelling is to get into "unstructured concurrency" land ...
(edit : sorry for noise -- just looked at the latest proposal -- would've deleted this but there's already a response :))
But that is the whole point, this isn't like a let. Two major differences pointed out upthread:
actor SomeActor {
// async let creates and runs its body in a new task, so its initializer requires sendability for any captured values.
func g(x : NSString) { ... }
func test1(x : NSString) {
let a = g(x: x) // Ok
async let b = g(x: x)+asyncthing() // not ok, because of Sendable check.
}
// async let runs its body in a new task, so actor self promotion happens.
var someString : NSString
func test2() {
let a= g(x: someNSString) // everything is ok
// Error, cannot refer to non-sendable property on isolated actor reference "self".
async let b = g(x: someNSString)+asyncthing()
}
}
Furthermore, the promotion of actor self from a "nonisolated Self" to an "isolated Self" introduces implicit suspend points given the proposed semantics, which is a huge concern from a logic correctness perspective:
func makeString() async -> String {... }
actor Actor2 {
func oneThing() {...}
func twoThing() -> String { ... }
func test2() {
oneThing()
let a = twoThing() + await makeString() // everything is ok
// compiles fine, but is NOT the same.
oneThing()
async let b = twoThing() + makeString()
}
}
Note the footgun that just happened there. If you spell this out explicitly, you can see what happened, because this would not compile:
// Error: cannot invoke doThing synchronously given a cross-task reference to actor 'self'.
@Future let b = { doThing() + await makeString() }
You'd be required to spell it like this, which makes it clear what is going on:
@Future let b = { await self.doThing() + await makeString() }
However, the proposal doesn't achieve this, and claiming that "async let is like a let" makes it extremely difficult to understand these things. The proposed behavior would just silently and unexpectedly introduced a suspension point between oneThing() and twoThing(), undermining the goal of the actor proposal which requires marking to make suspensions something we can reason about.
The proposal is not at all like a let. This is a different beast, and the syntax it is eliminating is load bearing.
Typically, the key feature of the child task is its asynchrony. The concurrency that you care about is typically not in what the awaited function does, but what it eventually triggers concurrently in a separate execution environment.
For example, if the awaited function is performing a network access, at some point it hands off a request to an URLSession.
Concurrency of the awaiter isn't important here, but asynchrony of the awaiter is, so that you can (for example) have two network requests in flight simultaneously.
In other words, you typically don't care about concurrency in getting to the point of handing off network requests, only concurrency in the network requests themselves.
let x = await won't let you have two network requests in flight simultaneously, but async let x = will ā because of the asynchrony, not because of the "additional" concurrency of starting a new task.
When talking about this I find very useful to differentiate a "local concurrency". let x = await is eventually concurrent in the context of a wider system, as in, it gives a suspension point so other things somewhere else can run. But locally is not concurrent. As opposed to async let x = which is locally concurrent, meaning that in the same function things will happen concurrently.
I'm not sure it helps anybody's mental model or just muddles the waters ^^' but I think it helps me understand the difference a bit better :)
In those terms, then, I'd state my reasoning like this:
Typically, the local concurrency of async let x = is not important (because that portion of the code consumes typically very little time and doesn't benefit from parallel execution), but local asynchrony is important (because that allows more than one non-local concurrent task to proceed in parallel).
Even recognizing that the mistake here was due to a proposal-reading race condition (ha!), I would venture this confusion would not happen if the alternatives were spelled as follows:
Task { await work() }
group.childTask { await work() }
@ChildTask let result = { await work() }
Just saying. Not that āchild taskā is the ideal name here ā just that a better name could help.
Agreed. (I hope we can all agree that whatever the name is, it should be consistent between the unsugared and sugared forms.)
Yes. Itās worth noting these arenāt equal valid but competing readings of the proposal: 2 is correct, whereas 1 is basically wrong unless you squint pretty hard. This becomes clearer in the desugared form, using my alternative name from above:
Note that there is no await at [b]. Spawning the child task doesnāt introduce the async effect. The async effect doesnāt appear at all as we create the task group until we get the value, at [c].
The effect then propagates up the call chain at [a]. Why does the withTaskGroup itself have an async effect in the first place? Why must we await it? Not because it spawns child tasks! No, itās because it awaits its child tasksā results (or their cancellation).
This all becomes less clear if we use group.async, and murkier still with the proposed sugar where async is in fact a keyword:
// ([a] is now implicit, and migrates to the enclosing block)
async let widget = makeWidget() // [bā], corresponds to [b]
doInterveningWork()
useWidget(await widget) // [c'], corresponds to [c]
Does the line [bā] introduce the async effect? In other words, is there a suspension point at that line of code? No! That line of code that says asyncis synchronous.
We can try to make reading 1 make sense if we squint and say, āThe let on line [bā] carries a deferred async effect that appears later when you use its value, sort of the same way an async function isnāt async until you call it.ā Thatās a bit garbled, though; itās really the hidden value ā a child task ā and not the variable itself that has certain operations that have the async effect.
It would be more accurate to say that async let places the value inside another hidden value thatās responsible for spawning the child task now and await its result laterā¦a wrapper for the property, if you willā¦.
But programmers are not required to spell it like that, and it's unlikely that they will, because this would be equally valid:
@Future let b = { await self.doThing() + makeString() } // bind
// ...
await b // use
Later on, if there is a bug during the test2 method with an unexpected task suspension, now they have to discern between two awaits: recognizing that one is in a closure of test2, so the suspension isn't actually happening at the binding; and the other at the use of b, which is the real place where there would be a suspension of the task that invoked test2. The omission of await around a single-expression initializer for an async-let just reduces that noise:
async let b = self.doThing() + makeString() // bind
// ...
await b // use
So, I don't understand how { await ... } is load-bearing. It requires thought to exclude the await when trying to narrow-down an unexpected suspension, and thought to ignore the closure when determining the type of b, since it is not (X) -> Y, but simply Y. That extra syntax just seems like it will be noise once people learn about async-let.
The reason why I added a use of the async-let binding, is because I think the moment we have at least one use, any lingering confusion about what is happening disappears. Hence the earlier posts by @ktoso and I about this principle.
Has any consideration been given to not including let in the declaration? Since async lets are really their own thing (and the proposal's comparison to normal let bindings aren't particularly convincing to me given the numerous differences), might it make sense to instead do something like (to borrow an example from above):
and make async a keyword by itself in this context? If we didn't want to overload async in that way we could equally use something like task/future/deferred.
On a different note: I'd be slightly opposed to making the syntax for fire-and-forget tasks heavier. In one domain I work in (game engines/entity component systems), it's common to dispatch concurrent tasks that mutate some state in-place, and having to have a list of await (systemOne, systemTwo, systemThree, systemFour) at the end of functions would feel clunky. Cancellation also isn't particularly useful in that context, so the early cancellation wouldn't be an issue ā the tasks would just ignore it.
I think the proposal addresses a significant piece of the concurrency puzzle, but I do share concerns about the design as proposed. I've studied the proposal in its various stages of development and mulled over the version as proposed for several days before summing up these thoughts. (Though hastily composed for lack of time; please forgive any sloppiness in writing or in the thinking behind it...)
I'll start with an issue that hasn't been brought up at all in this thread:
Autoclosures
The proposal states:
[...] in order to make it explicit that the await is happening inside the closure rather than before it, it is required to await explicitly in parameter position where the auto closure is formed for the argument:
func greet(_ f: @autoclosure () async -> String) async -> String { await f() }
async let name = "Bob"
await greet(await name) // await on name is required, because autoclosure
I understand the impulse here, but this rule is unprecedented in Swift.
For the most part, an autoclosure is appropriate only when understanding the "inside-the-closure"-ness of the argument wouldn't trip up the end user. Otherwise, it'd be more appropriate for the function to take a "garden variety" (i.e., non-auto) closure.
I'm thinking specifically of binary operators such as && and ||, for which the use of @autoclosure on the right-hand side permits short-circuiting to be implemented in Swift itself without magic. Users don't have to know anything about autoclosures to understand how to use an operator such as &&.
With this proposed rule, however (unless I'm misunderstanding), users would have to await explicitly on the right-hand side of && if they're looking to evaluate [the result of] an async let, but not on the left-hand side. This would leak a weird interaction between autoclosures and asynchronous programming to users who shouldn't have to think about this at all, and I think it's a tell that the considerations at play here aren't properly balanced.
(Of course, we could take inspiration from inout and allow operators to have a special exception here, but I don't think the analysis above is really any less surprising for ordinary functions.) In brief, I'd argue that the raison d'etre of @autoclosure is to allow the "inside-the-closure"-ness of an argument to melt away at the use site, and the proposed rule that would specifically call it out when awaiting an async let undermines that well-defined purpose.
Implicit context
No one has mentioned it yet I think, but I have to assume I'm not the only one to have found this example jarring:
assert(Task.isCancelled) // parent task is cancelled
async let childTaskCancelled = Task.isCancelled // child-task is spawned and is cancelled too
assert(await childTaskCancelled)
That the authors felt it important to comment on the meaning of Task.isCancelled is telling. Let me rewrite without comments and refactor just a tiny bit--
let a = Task.isCancelled
assert(a)
async let b = Task.isCancelled
assert(await b)
// Wow, these refer to *different* tasks.
This seems to be really the same problem as that which others have struggled with regarding the elided try: not requiring it seems inconsistent in some ways, but require it and all of a sudden you've got a problem because you can't surround the line with a do block and actually catch the error.
It seems to me that in both cases (the Task.isCancelled example and the question regarding try), the absence of braces actually demonstrates how they would be load-bearing if they existed, a visual marker of a different context to help users literally see what's going on. Consider:
let a = Task.isCancelled
async let b = { Task.isCancelled }
// Now it's clear that the second `Task.isCancelled` is in a different context.
func f() async throws -> Int { ... }
do {
// If we required `try`, then you'd write...
async let c = try f()
} catch {
// Can't catch the `try`, but why?
}
do {
async let d = { try f() }
} // ...well of course you can't catch the `try`.
I suppose that there is precedent for the brace-less spelling proposed for async let in lazy let, which also requires no braces and implicitly defers evaluation of the right-hand side. But unless I'm mistaken, that spelling is not susceptible to the issues seen here.
Overall, I think the proposal is right to look for an ergonomic way of spawning child tasks, but these two issues suggest to me that some fine-tuning is necessary in terms of what's elided and what's required by the sugared syntax proposed.
Non-suspending await
The proposal states:
[...] It might be possible to employ control flow based analysis to enable "only the first reference to the specific async let on each control flow path has to be an await ", as technically speaking, every following await will be a no-op and will not suspend as the value is already completed, and the placeholder has been filled in.
I agree with an earlier comment that it would be good to know more about how much and why the feature would be held up if we worked towards this end. It seems to me that we run the risk of diluting the meaning of awaits if the preferred way of spawning child tasks causes await to be used pervasively where all but the first use can't actually suspend.
Some control flow-based analysis (or at least, from the user perspective, something that feels very much like it) already exists in a common scenario in Swift: inside an initializer, the compiler is perfectly aware of when every stored variable has been initialized, and it is not shy about making that known
A version of Swift in which the compiler is not shy about making it known that it's keeping track of when awaiting an async let can actually suspend or not seems very much in keeping with this.
The naming here unifies all of the pieces of structured concurrency around async: async functions, group.async and async let provide normal control flow for asynchronous and concurrent code (try, throw, return, if, etc. all do the same things as in synchronous code), obey normal lexical scoping rules (e.g., child tasks never persist beyond the scope in which they are introduced), need to be await'd to get results, inherit important context like priority and task local values, and rigorously . async is the tool you should reach for for structured concurrency, but that's what gives you a programming model that's easiest to reason about and is the most optimizable.
Unstructured concurrency involves working with task instances, so new unstructured tasks are created with a completely different syntax that emphasizes task creation: Task { ... }. With unstructured concurrency, you get none of the affordances to help manage concurrency well. We absolutely need to have support for unstructured concurrency, but it's not the first thing one should reach for.
The naming emphasizes that the core distinction is Structured vs. Unstructured. Renaming async let or group.async means that we end up with more than these two categories, or multiple bits of divergent terminology within the same category (e.g., with async functions and group.spawn and Task.detached, why would I have any reason to think that spawn is part of structured concurrency?). So while it might make some things more explicit (yes, group.spawn spawns a new task of some sort!), it loses cohesion within the more important groups.
I also think it's fairly hard to justify that position that group.async { ... } is unclear about the fact that it's initiating new work running asynchronously from the call site, given that we've been using DispatchQueue.async to do exactly this for many years.