Hey, I wanted to share a couple of potential options for improving upon async let and (Throwing)TaskGroup. Let me know what you think!
Pitch: Improve Async/Await Parallelization Ergonomics
Introduction
I've recently run a poll on Mastsodon, and I was happy to learn that I wasn't alone in not having a good mental model of how to execute work in parallel using async/await:
Poll: do these requests execute sequentially or in parallel?
let (image1, image2) = try await (
session.image(for: request1),
session.image(for: request2)
)
40% of people answered "parallel", including me.
As a related area, I think it's fair to say that the APIs provided by both TaskGroup and its convenience wrapper async let are fairly complex and not easily discoverable. There needs to be a simple way to achieve basic tasks like performing an async map or executing two tasks in parallel without the need for intermediate async let properties. While some of it could be done by introducing new ad-hoc APIs like a parallel async map, I think there may be an alternative that better fits Swift.
Proposed Solution
This pitch outlines how Async/Await could support parallel execution with the first-class future type. The previous decisions were made early in the Swift Concurrency development, and with the new learnings and the renewed focus on ergonomics, it could be a great time to revise some of them.
Base scenario. There are two await calls and two clear suspension points β sequential execution:
let image1 = await session.image(for: url1)
let image2 = await session.image(for: url2)
A single await for futures in a tuple β parallel execution:
let (image1, image2) = await (
session.image(for: url1),
session.image(for: url2)
)
A single await for a sequence of futures β parallel execition:
let images = await [
session.image(for: url1),
session.image(for: url2)
]
A single await for a sequence of futures created using synchronous map β parallel execution:
let images = await urls.map {
session.image(for: $0)
}
The new API will compose well with throws, eliminating the need for a ThrowingTaskGroup:
let images = try await urls.map(session.image)
Mental Model
The mental modal for parallel vs sequential executing is simple: futures start executing immediately when they are created. If there is only one suspension point (await), the execution can only be parallel. This clears any confusion around the original example with a tuple.
Conditionally Asyncronous
One of the potential future additions could be an analog of the rethrows keywords but for async to make closures conditionally asynchronous if they have any suspension points. An async version of map could look like:
let images = await urls.map {
await session.image(for: $0)
}
If you apply the same mental model for parallel execution, since there is one await per URL, the execution will be serial. This version is not as important as you can already achieve the same with a simple for loop. The new version may open a way to eliminate some of the existing ad-hoc higher order functions.
Note: this version might be familiar for folks with functional reactive programming background, but it could also creates a situation where subtle syntax changes significantly alter the execution pattern, so it could be something to avoid.
Potential Implementation
When you invoke an async function, it returns a new Future<Value, Error> type. You can use await to unwrap it and retrieve a value. The await keyword can also be used on boxed types like sequences of Futures. The returned results isn't discardable.
The future starts executing immediately, which ensures that there is no changes to how task tree management works, including cancellation and priority propagation. If the async function requires a separate actor, the future automatically switches to it first.
There will be other questions like what happens if you don't await on a future, but, based on the precedence from other languages, it is a non-problem. It's not a common case, and you'll have to explicitly discard the future to do it (similarly to how you can invoke a callback-based function without providing a callback). It should continue running unless the task containing it is canceled.
The original structured concurrency proposal doesn't close the door on the idea of introducing explicit futures.
Language Fit
This direction is in line with the Swift language design. One similar early example is Optional, which was intentionally elevated to be a type and not a compiler primitive. The new type will require only the minimal compiler magic to allow using await on futures. It could also open possibilities for other novel APIs that compose well with other language features, just like Optional did.
Similar solutions in other languages: JavaScript's Promises and Kotlin's Deferred.
Source Compatibility
This change can be introduced with a new major compiler version bundled with other Swift Concurrency and Data Race Safety changes.
This is an oversimplification, but some if not most of the APIs are purely additive β the code from the examples won't compile under the current compiler. The only exception is a tuple-based await that compiles but currently uses serial execution, which is not clear or well-documented.
This proposal replaces async let, which can be deprecated. These properties will now have an explicit type instead of an opaque async let.
Potentially Big Nope: the new mental modal for await as a way to unwrap a future (or a boxed future, aka monad) is incompatible with the current behavior of it await being akin a try keyword and adding suspensions points for every async function in a subexpression (skews a bit more on a "compiler magic" side as the runtime can't see subexpressions).
Future Direction
This proposal focuses on a narrow problem β syntax for parallel execution β but it opens a door for a discussion about what other ways of using Async/Await it may enable by representing futures as types and decoupling their execution from suspension points (await). For example, one could define operators for futures that could be applied using dot-notation instead of the current situation that supports only higher-order functions.



