This is an "obviously critical" proposal for the Swift Concurrency direction, but I feel that this version of the proposal is "one step forward, one step back" vs the previous proposal, which I was pretty enthusiastic about. I have put a lot of time and energy into this and neighboring proposals.
While I agree that these are related, the async let
proposal is a syntactic sugar proposal for a narrow part of what this proposal covers. It still has serious issues that need to gel, so I recommend that we get the foundation right without over indexing on a sugar proposal that needs more iteration. Thank you for mentioning this though, because one of the threads of discussion from that should absolutely be pulled in here (below).
Here are detailed thoughts below:
The move to embracing unstructured tasks, support for the creation of tasks in sync modifiers, are all really great. That said, I'm concerned with this direction:
let dinnerHandle = Task {
try await makeDinner()
}
let dinner = try await dinnerHandle.value
This is a subtle but really problematic conflation of two different ideas: a task is an independent process/thread/task that does some computation. While we often specify these as functions and functions have a return value, these often yield many values during their computation and have side effects. This is why we have things like AsyncSequence
in proposal, while generators are a thing in other languages, etc.
The problem with this API direction is that it is incorrectly conflating Task
s with Future
s by using Task
as a standin for a future/asynchronously-completed value. Outside the simple cases, Tasks can produce streams of values, and can return multiple asynchronously completed values as independent futures. This becomes important when you start composing async tasks out of multiple other async tasks which may be detached - and detached tasks emerge extremely quickly when you branch out to IPC and RPC situations.
Furthermore, Actors need to interact with all the same sort of functionality, so it feels that this is a feature best modeled as a new library feature that is orthogonal to structured concurrency and actors, not something that is "part of" the structured concurrency proposal.
The only rationale I see for this is in the changelog, which says:
collapse
Task.Handle<Success, Failure>
intoTask<Success, Failure>
. This is the most-used type in the Task API and should have the shortest name.
To be clear, I super endorse giving Task.Handle
a better name. I am arguing that it shouldn't be conflated with Task
. I am suggesting that we should introduce a new top-level word like Future
, since this concept will crosscut structured concurrency and actors. This issue was also raised on the "async let
" proposal thread.
The biggest change in this proposal is moving from the unifying word spawn
(an active verb) to the word async
(an adjective) when creating new tasks. While the /semantics/ of this operation are good (and I think pretty well nailed down by this point) this /naming/ move is a big step backward, for several reasons. The most important of which is:
-
Async isn't a technically correct word for this operation. The fundamental concept of a
async {}
child computation or agroup.async {}
child computation is happens independently and typically in parallel with the current task. However, the wordasync
in the Swift language means "potentially suspends". It does NOT mean "happens concurrently". Conflating these two is a huge problem to me, and I think this will make it much more difficult to teach and learn Swift concurrency.This issue is also raised in the
async let
thread discussion, observing thatasync
is our second effect and that we should learn from precedent of our first effect (throws
):There are strong reasons why error handling has multiple "words in the lexicon":
throws
for the effect,try
for the marker,Result
for the "handle" type when erasing to an uneffectful function, anddo/catch
when introducing a new catch-processing region. I think that all these things are substantially different and are worth different "words" to clarify them.In the case of Structure Concurrency, the former version this proposal had a stronger design: it used the word
async
for the effect, it usedawait
for the marker, it uses "TBD" for the future abstraction (this is the juicy center of theasync let
proposal that we haven't gotten to yet) , and it usedspawn
as the equivalent fordo/while
that introduces a new independent concurrent region. -
The
group.async {}
and top levelasync {}
operations create a new Task and start it executing in parallel. However, the wordasync
is an adjective, not an active/imperative verb. This directly contradicts the guidance in the published Swift API Design Guidelines, which says we should use an imperative verb here. -
The rename create weird APIs that don't make sense: if you aren't a "Swift Concurrency Expert", what would you expect
asyncUnlessCancelled
to do? -
This proposal fractures the "attached" and "detached" world. Where it proposes the spelling
async {..}
for attached tasks, it proposesTask.detached {..}
for detached tasks. We want people to use attached tests where possible, but we shouldn't fragment the API this way. If we go with the termspawn {..}
then the natural term isspawnDetached {..}
which would pull these things together into a unifying framework, make the different clear, and slightly nudge programmers towards attached tasks. -
Version 3 of the proposal continues to use this verb pervasively to explain itself, e.g.:
group.async
spawns a child task in the task group to execute the givenoperation
function concurrently.As well as sections like "Spawning TaskGroup child tasks". If people will continue to think about this operation as "spawning" something, then we should just embrace that, particularly without rationale for a change.
-
Beyond the problems with renaming this operation to
async
there is no motivation for doing so -spawn
was discussed extensively in revision #1 of the proposal and we agreed that it had a lot of prior art and is an active verb that successfully conveys "creating a new thing" concisely.
To recap: the move to the non-verb "async
" for this operation is a big step back.
We don't need to stick with the word spawn
, but if there is a problem with it, it would be better to air that problem so we can solve it. Moving to overloading an adjective effect modifier isn't a step forward.
The proposal suggest different spelling for the TaskGroup
case vs the global case: group.async {...}
vs Task {...}
, which both inherit metadata. The family also includes Task.detatched {}
and asyncUnlessCancelled {}
.
I think it would be much more uniform to go with spawn {}
, group.spawn { }
, spawnDetached {}
, and spawnUnlessCancelled {}
as discussed in the previous round of the proposal.
Per the above point, this modeling:
struct Task<Success: Sendable, Failure: Error>
Seems wrong. In generality, a task can return multiple different results that have different lifetimes (consider a Task talking to a name server and a computation server independently) and tying their lifetime together seem unnecessary and limiting. It seems better to decouple "spawning" the task from "constructing the object", which allows providing more expressive APIs without sacrificing ease of use.
The move to change the withTaskCancellationHandler
is a great move. Changing the onCancel
member to be second will lead to more consistent and fluent APIs. A+
Agreeing with the discussion upthread, the design of the sleep
API seems like it would benefit from further discussion, crosscuts actors, seems like it could be split out to a subsequent library discussion. This proposal would be easier to read if it were focused on the mechanics of spawning and interoperating with tasks, independent of the values those tasks create (AsyncSequence, futures, etc) and the things they may want to do (sleep, open files, etc).
-Chris