[Pitch #3] Async Let

Hello everyone,
We separated out and updated the async let proposal with the latest semantics and spelling details.

This is the first pitch of the feature as a stand-alone thing, it was previously pitched as part of structured concurrency pitch #1, and pitch #2 and was separated out later on as it's a feature large enough to deserve it's own discussion.

It is partially implemented on today's main branch and available in nightly snapshots.


Changes since the original pitch are mostly focused around additional discussion and details around exact semantics of the feature, e.g. we added:

  • longer discussion of throwing semantics
    • this is an area we'd welcome discussion of. I.e. currently if an async let has a throwing initializer, and is not awaited explicitly, the task is awaited on implicitly but the thrown error is silently dropped. This is equivalent to TaskGroup semantics and in line with "since you didn't await on it, you clearly don't care about it" however it may have some tricky implications.
  • more details about cancellation handling

Read the full proposal here.

Please use this thread for feedback for the feature, and the PR to Swift Evolution for fixing typos or minor wording mistakes. Thank you!

7 Likes

Did TaskGroup.spawn change to TaskGroup.async from this last review which still haven't officially concluded? ↓

I like spawn keyword that align with TaskGroup.spawn and explicitly describes that spawn execution process which runs in parallel.
The word async doesn't have any hints about this execution flow.
With language specs accepted previously, async is serial execution model with await.

What happens when I change this code:

// func doOtherStuff() async;

func doStuff() async {
    await doOtherStuff()
}

to this:

func doStuff() async {
    async let t = doOtherStuff()
    await t
}

Assume all of this code is in a class with @MainActor (i.e., we care which executor it runs on and want to stay on that executor both in doStuff and in doOtherStuff).

I think if those two code blocks do different things then this feature is going to be extremely confusing. Also, if those do different things then we're missing a feature that allows you to separate the initial call from the await without changing the executor that the call is run on.

async let would be extremely useful if it only let you separate the await from the initial call without otherwise changing where the code executes. Using it as a way to implicitly detach the whole call and move it to another (unspecified) executor seems like just a confusing and unnecessary feature.

1 Like

Should it be required to always await any async let declaration?

That seems impossible to enforce if we allow async let to be passed to closures: closures may never be executed but you generally don't know that statically.

I think it's fine to cancel the task and discard the result, whatever it might be. If the task gets cancelled (and actually check for cancellation to stop early), whether it would report an error, and the kind of error it reports (cancellation error or something else) is racy and unreliable.

Although, definitely make it a warning if the async let value is not awaited on any code path (like for unused variables).

Implicit async let awaiting

I wonder about the semantics for a case like this:

func go() async { 
  async let f = fast() // 300ms
  async let s = slow() // 3seconds
  if Bool.random() {
     await print(f)
  } else {
     await print(s)
  }
  return "nevermind..."
}

If you look at ARC, Swift will happily deallocate referenced objects as soon as they're no longer used by the current code path. If we were to follow a similar optimization pattern here we'd get this:

func go() async { 
  async let f = fast() // 300ms
  async let s = slow() // 3seconds
  if Bool.random() {
     // implicitly: cancels s?
     await print(f)
  } else {
     // implicitly: cancels f?
     await print(s)
  }
  return "nevermind..."
  // implicitly: await f
  // implicitly: await s
}

But the proposal seems to say it'll cancel only when reaching the end of the scope. Is early cancellation desirable? Is there a reason to do things differently from ARC?

1 Like

I answered a bit too quickly here, let me revise my post a bit.

Right yeah this is a change after the 2nd review of the structured concurrency proposal indeed.
It didn't yet get a proper summary writeup but as Ben mentions, we are working on revisions based on the review:

You can already view all the changes for the 3rd review of structured concurrency over here: [Struct Concurrency] Update for 3rd review by ktoso · Pull Request #1345 · apple/swift-evolution · GitHub

--

So, why async?

The spawn word came up because of the analogy with "spawning threads" and it read quite nicely with group.spawn.

Sadly, it did not read well in other contexts. Most notably, spawn let was not a spelling we felt comfortable with, and the previous spelling of async let really looks much more awesome and was met with some enthusiasm in prior reviews.

Because of that, I and the other proposal authors (@Douglas_Gregor @John_McCall and @Joe_Groff ) would want to keep the async let spelling. We also introduce the new, and very important, async {} operation in the 3rd structured concurrency review ([Struct Concurrency] Update for 3rd review by ktoso · Pull Request #1345 · apple/swift-evolution · GitHub), which makes for an odd situation:

  • group.spawn - only the group uses the spawn word
  • async let
  • async {}
  • detach

Because we have a mix of different words to mean creating a task. Given that spawn is the odd one here, we decided that we can stick to using the word async consistently to mean creating a child task, this way we arrive at a consistent API in all contexts:

  • group.async { }
  • async let
  • async {}
  • and asyncDetached {}

We can say that "async creates async tasks" and that's always correct. Specifically, the group's async and async let create child async tasks. And the other APIs create async tasks which are not child tasks. Overall we feel this is a simple rule to follow, and does not introduce yet another new word into the swift concurrency vocabulary.

Hope this helps!

12 Likes

I think it might be a bit too weird to be so aggressive with cancellations.

I think the analogy to get inspired from is more task groups and general structured aspects of concurrency rather than ARC specifics. We do differ in cancellation a little bit from task groups, in the sense that task groups never implicitly cancel on normal return (they only do so if you throw out of the withTaskGroup).

So while it is not implemented directly "as" a task group, it should exhibit the same semantics as if we wrote it like that:

func go() async { 
  let f = withTaskGroup { g_f in 
    g_f.async { await fast() } // 300ms 

    let s = withTaskGroup { g_s in   
      g_s.async { slow() } // 3seconds
      if Bool.random() {
         await g_f.next()!
      } else {
         await g_s.next()!
      }
    } // end g_s, always await all g_s tasks
  } // end g_f, always await all g_f tasks
  return "nevermind..." // total time always 3 seconds
}

We make the difference between automatically cancelling not awaited tasks in async let but not doing so in groups because in groups you cannot easily say which value you are not awaiting on. There is no way to express that in groups. But the general "end of scope" idea carries over.

With async let not awaiting on something sends a clear signal that you did not care about its result, so at the end of the scope–where a task group would automatically await–an async let also automatically awaits but also cancels.

That's the rationale for cancelling at ends of scopes for async lets. It is a small difference between these and task groups indeed but we thought an useful one.

1 Like

Thanks for information.
I think it's a good idea to use common word "async" to names of all task generation functions.

2 Likes

To me, not caring about its result also means not caring about its completion.

Using a local variable for keeping an object alive doesn't work in Swift, so I would not expect it to work for keeping a task alive either.

Likewise, never reading a local variable is a warning, and similarly it should be a warning here to never await on it.

2 Likes

Yeah but then what about:

async let _ = runRunRun() // 🏃‍♀️ 

// currently this just runs fine, and cancels at end of scope.
// should this be immediately cancelled instead?

Because unused variables would trigger warnings, and I might want to create a child-task to runRunRun() because it’s doing some side effecty thing.

This is not exactly replaceable with async{} because that wouldn’t be a child task and therefore is heavier on allocations etc, so:

async { await runRunRun() }

would be the “right way” to fire-and-forget but it also is heavier on allocations — needlessly so, because we never wait for the result. This is where (I personally), really wish we had send runRunRun() for fire-and-forget operations :thinking:

This is one of the tricky things with this design I personally struggle with, but don’t have enough production experience with async yet to really know if it’s an issue or not, but it feels like it could be — fire and forget is a thing still, even in our structured world.

Love the design and philosophy of this proposal. Assorted thoughts follow.


I really appreciate heuristics like this. Even if they don’t reveal the full structure of the abstraction, they’re a useful compass, crucial to progressive disclosure.

That heuristic isn’t quite correct though, is it? When async appears in a statement, it creates concurrency, but when it appears in a declaration (or in a function type), it means “must be called in a context that can handle concurrency.”

By very loose analogy, it’s as if Swift used the same word for both throw and throws…or maybe more like if it used the same word for throws and catch.

I realize this isn’t likely to change, but it still chafes a little. I’m fairly certain it will look at first blush to Swift concurrency newcomers as though func foo() async { … } makes the entirety of foo() execute asynchronously when called. Here behold the Ghost of Heavily Upvoted Stack Overflow Questions Future.


unlike Task.Handles and futures that it is not possible to pass a "still being computed" value to another function

In general, this makes sense under the structured concurrency model. I wonder, however whether it precludes too many common basic refactoring patterns (e.g. splitting a long method into shorter ones). Might it be possible to allow “passing a handle” to direct function calls, and allow non-escaping closures to capture them? Perhaps representing the still-concurrent value as a () async -> T closure, perhaps in a more sugared form?

It seems like as long as the future / handle / “still being computed” value of x in async let x is not Sendable, then it should be safe to pass around without violating structured concurrency. What am I missing here?


This is a confusing phrasing:

By default, child tasks use the global, width-limited, concurrent executor, in the same manner as task group child-tasks do.

At first blush, the sentence appears to be saying, “child tasks use the same executor as child tasks.” ?!

I think the intention is “By default, child tasks created with async let use the same executor as child tasks created with withTaskGroup.” Am I reading that correctly? Some wordsmithing in that spot might help.


Is there a missing bridge between the world of async let and the world of manually created task groups?

Suppose for example that we want to execute heterogenous tasks and there is an arbitrary number of one of those tasks. Taking the makeDinner() example from the proposal, suppose there are veggieCount vegetables instead of exactly one, and we want to chop them all concurrently. It’s clear how to do this with the manually created task group:

return try await withThrowingTaskGroup(of: CookingTask.self) { group in
  for n in 0..<veggieCount {
      group.async {
        CookingTask.veggies(try await chopVegetables(n))
      }
  }
  group.async {
    CookingTask.meat(await marinateMeat())
  }
  …
}

…but now we’ve inherited all the headaches of the manual group. What if we want to keep using async let for meat and oven while supporting the arbitrary number of veggies?

I think I know the answer to my own question: the veggies might be an AsyncSequence, or we could use withThrowingTaskGroup(Vegetable.self) just for the veggies. Is there any disadvantage to this? Is there a situation where I want to ensure that veggies, meat, and oven are all siblings in the same task group? Or is this simply not a realistic concern?

2 Likes

I think it should be immediately cancelled. It's going to be cancelled at the end of the scope anyway, and since it runs in parallel it might or might not complete before it gets cancelled and you'll never know. Cancelling immediately makes whatever side effects the task has more deterministic, and thus easier to debug.

If you're actually trying to create a child task that runs in parallel but is cancelled specifically just before the scope ends, then you'll need withExtendedLifetime or some equivalent. I'm not sure why you would want a "fire and forget" task that must be cancelled at a specific time though.

And if you're counting on the task not being responsive to cancellation (so that it completes despite being cancelled), then I think it's misusing the concurrency features. It should never be wrong to check for cancellation and terminate early. So I say it's a good thing if you get a warning about an unused variable in this case.

5 Likes

Me too.

Heh, seems today isn't a good day for my brain... :brain: Stumbling in wording and details a lot today somehow.

This actually could not be more efficient than a plain async{}, in order to not-await on things we can't task-local allocate the resulting task so it would be equivalent to async{}. I guess this only leaves some spelling sugar discussions for the future here say async runRunRun() but that's outside of the scope of this proposal.

I find this a very weird concept of "consistent".

Let me number these for brevity of reference:

  1. group.async { }
  2. async let
  3. async {}
  4. asyncDetached {}

Both #3 and #4 are not child tasks, so the significance of the word "detached" to any reasonable user of Swift is "on a different executor". The absence of "detached" in #3 means "on the same executor".

However, the absence of the word "detached" in #1 and #2 does not mean "on the same executor". Those run "on a different executor".

A very weird concept of consistency!

For the sake of discussion, let's use the word "attached" to mean "on the same executor", and for the sake of discussion let's add the qualifier words to the syntax:

  1. group.asyncDetached { }
  2. asyncDetached let
  3. asyncAttached {}
  4. asyncDetached {}

Now, I'm 100% in agreement with @adamkemp that there is one vitally important thing missing from this list:

  1. group.asyncDetached { }
  2. asyncAttached let // <-- we need this, please!
  3. asyncDetached let
  4. asyncAttached {}
  5. asyncDetached {}

I think that asyncAttached is just as important, and has just as many use cases as asyncDetached.

This is a consistent list. If we want to drop any qualifier words, then "attached" seems like a good candidate:

  1. group.asyncDetached { }
  2. async let
  3. asyncDetached let
  4. async {}
  5. asyncDetached {}

That is also a consistent list, with bonus conciseness. That list would allow newcomers to reason about what they're getting in all 5 scenarios.

I understand that these are not the spellings that the authors of this pitch would prefer, but if you're going to invoke consistency as a design principle, then you should actually be consistent.

I also understand that by introducing a 5th construct, I've subverted the intentions of the authors, because the most-easily-reached-for version of async let becomes async/*Attached*/ let instead of asyncDetached let. Personally, I don't think that's a bad thing, especially if actual consistency makes the slightly more verbose asyncDetached version easy to find and easy to reason about, too.

Edit: Aside from this issue, this pitch looks pretty great to me!

4 Likes

Let me add my reasoning why asyncAttached let (however spelled) is important to have.

According to my current understanding of the concurrency pitches, if we have code running on a concurrent executor, then asyncDetached let and [hypothetical] asyncAttached let would be more or less equivalent in most cases. IIUC, both would allow the spawning and spawned tasks to execute in parallel.

In code running on a serial executor, such as the main thread, asyncAttached let seems a better default choice than asyncDetached let, because running "on the same executor" would help to avoid data races and other thread safety issues. It should require thought and deliberate choice to introduce parallelism to this serial execution environment.

So, I see no harm and much benefit in making async let be the attached version.

This seems like a much cleaner separation of concurrency than is offered in the Future-based designs of .net/JS. In those cases, the concurrency is implicit by not awaiting, whereas with this it’s explicit.

Implicit await - could this use the presence/absence of @discardableResult to require the await? This would then match the behaviour of synchronous returns.

actor isolation - I think the pitch should state explicitly what the behaviour is of an async let running in an isolated (global) actor context. It states that it inherits the priority and task local state, but i wasn’t clear if it also runs in the same actor isolation context.

1 Like

As far as I can see, this composition of syntax is arrant nonsense and should not be allowed.

I can see that it can be assigned a usable meaning, but any such meaning seems contrary to what async let is for, and the existence of async let _ requires developers to have an unnecessarily complex mental model of how it works.

If it actually does allow better performance, that’s an opportunity for optimization of other syntax options, or possibly a new advanced-use-case runtime function.

It's a bit harsh to call it nonsense, I would ask for a deeper analysis and reasoning rather. I myself have a lot of open topics to discuss here, but let's not immediately call things nonsense and leave it at that, we should weigh the tradeoffs and alternatives when saying that a piece is not desirable.

Another example to consider is what to do when one wanted to create a child task for a void returning function -- async let _ = returnsVoid() is an not entirely unreasonable way to spell this.

Although arguably it might be nicer to say async returnsVoid() just like that, but that has not been really designed and may mean the abandonment of async let entirely?

Problems with that though:

  • It would also muddy the waters between async { await returnsVoid() } (not child task) and whatever the spelling of <thing> returnsVoid() would be.
  • It likely means we need new words for it, yet we just came back from reducing the number of words this proposal introduces...
  • it still is something we will await on, unlike async {} so how to we make them different enough and understandable. The spawn word we are trying to avoid for a number of reasons - @Douglas_Gregor was digging into this a long time and has thoughts here.
1 Like

I had a similar thought of requiring a @ declaration of some sort (similar to @discardableResult) to indicate that we just want to implicitly cancel/await things, and if that @ declaration wasn't there, then the compiler would force you to await or cancel explicitly (with a fixit to add the @ declaration)

I believe it is a property of the call site (calling code) if they want to wait for a result or not. So making the decision if it can be not awaited on based on the declaration itself I’m not so sure about... :thinking: