I am -1 on this particulars of this proposal, but +1 on solving this. This proposal is a strong step in the right direction, but it is an overstep.
Something like this fits into the general direction of Swift. I haven't used similar language support anywhere else (I'm not aware of it existing). I put a bunch of effort into this in pitch threads and read the proposal carefully.
First, as an important technicality, it is highly irregular to have open questions in the proposal. The proposal should take a stand one way or the other and put the alternative in the alternative's considered section. This framing makes it difficult to understand what the proposal is proposing. I do include my opinion below though.
Getting to the actual review, I agree very much with the motivation section: we need to improve the syntax, safety and performance of the common "ad-hoc structured concurrent child task" case. This makes a lot of sense to me, and I agree that TaskGroup is syntactic overkill for this important special case.
However, this proposal is killing a mosquito (maybe a bird?) with a bazooka - it is conjoining a large pile of semantics and erasing a bunch of syntax, built into a new declaration modifier that we already use for other things in the language. I realize that anything in this space will invariably be syntactic sugar: I'm not arguing against sugar -- I'm arguing against this take on it.
There is a big spectrum from which we can produce solutions for this problem. The TaskGroup
API is unacceptable as pointed out in the motivation section. I think we should explore a property wrapper based approach that will allow capturing the majority of the benefit of this proposal while maintaining some of the syntax that was jetison'd. This was not explored in the alternatives considered section.
I haven't implemented or prototyped this, but I think that instead of:
async let veggies = chopVegetables()
async let meat = marinateMeat()
async let oven = preheatOven(temperature: 350)
let dish = Dish(ingredients: await [try veggies, meat])
return try await oven.cook(dish, duration: .hours(3))
we could build on the recently added improvements that allow async properties to go with a property wrapper + closure approach. This would give us a simple design that composes better with existing-Swift, and reduces the amount of reinvention:
@ThrowingFuture let veggies = { try await chopVegetables() }
@Future let meat = { await marinateMeat() }
@Future let oven = { await preheatOven(temperature: 350) }
let dish = Dish(ingredients: await [try veggies, meat])
return try await oven.cook(dish, duration: .hours(3))
This approach would also be a HUGE improvement over the code in the motivation section. It has a number of differences from the proposed approach:
-
It is dramatically less invasive on the core compiler, which is a good sign it will compose better with other features.
-
It builds on the existing functionality we have to wrap a property (property wrappers) instead of inventing new type system rules through a decl modifier.
-
It doesn't overload the
async
keyword to mean something different than the effect modifier. To point out one problem,throws
andasync
are both effects and should be generally treated equally: we don't want to supporttry let x = ..
as a deferred throwing feature. -
By using closure syntax, we more clearly delineate that the initializer expressions are not part of the enclosing scope. This matters because they do/should introduce a
@Sendable
check, they should change the behavior ofactor self
to require an sync jump, and will have other effects. This is load bearing syntax, not boilerplate. -
By making the closure explicit, it means that we can bring back the
try
andawait
modifiers in the initializer list and it makes sense what they mean and they are now consistent with the rest of the language. -
The use of closure syntax allows us to use multi-statement computation of the value, not just a single inline expression. This is more powerful.
-
Using a property wrapper means that you get the existing property wrapper behavior with tuples, you probably don't get the fancy stuff described in the pattern section. I personally find this to be a /good/ thing. The pattern section describes a bunch of special cases that are confusing to me, seem like there are likely to have complicated interaction issues, and (in my opinion) should be split out to their own proposal if they stick around. If property wrappers + patterns aren't what we want here, we shouldn't "innovate" in core language semantics as part of this sugar proposal.
The major challenges I can see from a property wrapper approach are:
-
Slightly more syntax. I don't consider this a downside because it makes the behavior much more clear. However, if this were a problem, we could change the property wrapper to take an autoclosure, and could introduce attributes to suppress marking modifiers. Such moves should be very carefully considered though.
-
We want to make sure the value isn't copied and doesn't escape for safety and to enable on-stack optimizations. This is something that will make major progress with the Ownership Manifesto but we don't want to wait for that. I would recommend adding a special
@_magic
attribute to theFuture
property wrapper that enables this, and tie the hard coded logic to this attribute in the compiler. Keeping this orthogonal would allow this attribute could be used with other things in the Swift Concurrency design that shouldn't escape either (e.g.UnsafeTask
was one random example that was discussed and was dropped because there was no checking for it). -
You want to do on stack optimizations for these values, and you want a special "destructor" for these values when they go out of scope. As the authors know, the Swift language model supports custom destructors, so those can be added once #2 guarantees these values don't escape. The actual optimization should be straight-forward to do in SILGen or in the SIL optimizer for types that have the
@_magic
attribute. Such optimizations should apply to anything with this attribute on it, which would be a lot more general than what is proposed here.
On the open questions in the proposal, these are deep questions that have to be resolved:
Under the current proposal, this function will execute the
registered
task, and before it returns the "Hello …!" it will cancel the still ongoing taskregistered
, and await on it. If the taskregistered
were to throw an error, that error would be discarded! This may be surprising.
Yes, I agree this is surprising. I see this as a syntactic sugar proposal for task groups, and the issue here is that it is sugaring away the try on the task group itself. I personally think that sugaring away the task group is the right thing. Given that, you get to this point:
It might be better if it were enforced by the compiler to always (unless throwing or returning) to have to await on all
async let
declarations.
The problem is that the compiler can't do that correctly - this is a dynamic property you're talking about, not a static property, and solving this in full generality is equivalent to the halting problem. We could do a flow sensitive approximation for this, but that will be conservative, and a pain for users in any case where you have conditional control flow.
There is also the question of what is the behavior you want - it seems like a thrown error should be catch
able, even if you don't touch it. This means the dtor for the future throws:
do {
async let veggies = chopVegetables()
if someCondition {
// throws if veggies had an error.
try await use(veggies)
}
// do we throw the veggies error at end of scope if !someCondition?
} catch {
// do something
}
Alternatively you could have the "destructor" for the Future trap if the enclosed value throws, but I personally don't think that is an acceptable user model - it would be a huge foot gun.
That said, I agree with the last line of the section:
Another potential idea here would be to allow omitting
await
inside the initializer of aasync let
if it is a single function call, however do require thetry
keyword nevertheless. This at least would signal some caution to programmers as they would have to remember that the task they spawned may have interesting error information to report.
The functional issue here is that the destructor of the future throws when it goes out of scope if it hasn't been try await
d dynamically. With Ownership we will have explicit destructors, and will have to decide if those are allowed to throw (C++ precedent says that is a bad idea, but this sugar proposal is forcing the issue). The best way to model this that I can see is to require a try
on the let
declaration - that makes it clear that the scope can be exited and composes with other try
marking checks that depend on a syntactic marker. This would give you:
// The try here means the dtor for veggies can throw?
try async let veggies = chopVegetables()
alternatively we could invent a modifier on try
, perhaps try(dtor) async let veggies ...
The big issue is that I don't see how to evaluate the tradeoffs here without usage experience.
Zooming back out, I'm in favor of addressing this problem, but I have two meta concerns:
-
I prefer smaller features that compose together instead of mega features that pack a bunch of different things together into one specific language feature. We have a lot of values that want to be on-stack-not-escapable, so we should solve that problem. We have property wrappers already, so we should use them. If there are other things that "need" to be further polished off this sugar proposal then we can investigate them one at a time, instead of as a big bundle.
-
This proposal appears to be trying to eliminate as much syntax as possible -- even though that syntax is /load bearing/. Swift strives for clarity and predictability, not for minimality of syntax.
-Chris