SE-0304: Structured Concurrency

Sounds good.

Ok makes sense. Sorting these two out seem important since they're the same thing, just one is async and the other sync. I hope this will settle out cleanly as the other proposal comes in.

If that is the case then why keep the async one? Just drop it and only have the sync one and you should sidestep the whole overload issue.

Cool thx.

I think this is a very good point. I would recommend changing this to be sync, and then have a "addWithBackpressure" future overload that would be async for the clients who care. It doesn't seem like something that everyone should be forced to eat the complexity of when most people won't care (progressive disclosure of complexity and all).

Also, per upthread comments, I think that "TaskGroup.add" should have the word "run" in it for consistency. "withGroup" creates a great, but "add" actually creates a runnable thing and reflecting that in the name seems like a good thing. Alternatively, your suggestions of launch, spawn, etc could all be chosen instead of run with different tradeoffs.

This is what I meant by "not being able to express it in the type system. I think the solution here is to have two different Task.Group types, and have a "Task.withGroup" and "Task.withThrowingGroup" (better names welcome ;-) that return a Task.Group and Task.ThrowingGroup type respectively. This allows the add and next methods to have the right result signature for the different use case. You might be able to overload the two Task.withGroup's based on the throw-ability of its body?

Oh I see you suggest this in "duplicating types"; exactly that.

This is pretty important because effects like 'throws' pollute the entire call stack. I think this is probably worth it. typed throws with support for "throws Never" in generic situations would make this all pretty and generic instead of copied, but we don't have that yet.

Is there an overly clever thing that you could do by making "group.next" be inlininable and contain 1) the assert and 2) a call to an uninlined nextImpl? This would allow the client to disable the assert based on its idea of whether it is debugging or not.

The right answer here is to get ownership types into Swift of course.

Thanks for all the hard work!

-Chris

4 Likes

Thanks a lot Chris, very useful feedback here.

Task.current

The async one is the only way to get a task without touching an API that has "unsafe" in the name (or some form of "unsafe" in the name), so I feel we need it.

To reiterate, the APIs to get a Task are only those:

let task: Task = await Task.current // once async properties
let unsafe: UnsafeCurrentTask? = Task.unsafeCurrent
let task: Task? = Task.unsafeCurrent?.task

So we can't easily get a task without doing quite a lot of ceremony in an async context if we removed the async property: 1) we'd have to dance with the optional 2) we'd have to use an unsafe API to obtain it.

It might be true that people rarely should need an actual Task because all useful functions we most functionality is available statically (Task.isCancelled etc), but still, it's useful enough not to make it super ugly and through unsafe API hm. A weird but important example is getting a Task once before a hot loop, and only task.isCancelled checking it inside the loop -- this avoids continious thread local lookups (typical pattern recommended in intel guidelines etc...).


Throwing / not throwing Group

Right yeah, it's some duplication. I also poked around with some sneaky tricks how perhaps we'll be able to not duplicate the types... (though it's a bit nasty, the add would also only be conditionally added to the type, depending on Failure == Never and Failure == Error :thinking:).

I'll PoC this a bit more but I think we agree that we need to support this non-throwing group nicer. I'll get back to the review with the specific shape how to achieve this soon.


No idea if we can pull such trick... :thinking: Maybe @Joe_Groff will know.

1 Like

Ah, missed one of the important ones:

Group.add

Yeah for consistency it seems like it might want to have run in the name.

I'm a bit concerned about making "the one we want people to use", i.e. the backpressuring one (if we indeed decide to keep it) have a long name. My philosophy with names has always been to give the shortest name to the API I want people to use, so here this would be await group.run { ... } or await group.attach { ... } or await group.add { ... } etc, and an "add unconditionally" one would have a longer more annoying name, like group.addForcefully { ... } or group.run(ignoringCancellation: true) { ... } or something like that. Fishing for ideas here, and I'm not sure what we'll do with the suspending version -- we should decide if we'll indeed be using it or not :thinking:

I'm also tempted to talk about launch and detach but I guess they're just "cute names" I like, and not necessarily adding to the clarity, so I guess the more explicit names may be fine.

I'm bike-shedding here, but submit() feels worthy of consideration too.

2 Likes

Proposal looks great. I do have a question about detached tasks.

As it stands detached tasks can take an UnownedExecutorRef to customise the where the task should be scheduled. But I feel like there is a missing argument as to 'when' to schedule a task. The nature of a detached task is that it should just be executed and you don't care about any returning values etc, but it's quite common to want to execute something after a period of time e.g firing off a UI animation at a certain time in future. In that case you don't care about the return values, you just care that it gets executed after a delay of some sort.

I guess my first question is; is this the right place for that consideration? If so is it within the scope of this proposal or a possible future direction?

1 Like

Hi Dale,
thanks for asking — we are not proposing any “timer” or scheduler APIs in this pitch.

For what it’s worth you are able to use dispatch and/or NIO and their usual “execute after 10 seconds” or similar mechanisms. The proposals here do not touch the notion of time at all.

We may get to such APIs in the future, but it is not in the scope of this nor any currently “upcoming” proposals. The idea is that it is good enough to detach and use existing timer APIs for now.

—-

Speculation mode on: I don’t think such timer API would be the runDetached itself, but rather there would be some new Timer API that would offer the scheduling capabilities you’re asking for.

“You can already do this”-mode on: Technically we have an “async delay/sleep” as well already... you could runDetached { await Task.sleep(...); doThing() } and this will be pretty efficient actually :slight_smile: The sleep API, as anything talking about time is very limited right now and is definitely future work to talk about time in terms of Deadline and TimeAmount etc... We are not going o do this in this proposal though.

4 Likes

I thought that might be the case but it was worth asking anyway. I wasn't aware of the Task.sleep(..) api, so that's an interesting direction to explore in the mean time. Thanks!

1 Like

What happened to the idea of a function attribute to avoid having to use an await for async functions that are guarantied to not suspend? That seems like the solution here.

That guideline would be self-evident if the syntax reflected the tradeoff. For instance, this could be the normal way to check for cancellation:

Task.current.isCancelled

Now it feels like two operations. It's intuitive that you can make it a bit faster by not repeating the Task.current part for each loop operation.

2 Likes

I don't understand. It would just be a sync function that returns a Task?. How would that be unsafe?
The clients don't see the implementation details, and the signature makes it safe.

The only change I'm suggesting is that you make the first one sync and return a Task?. The later two would continue to work. If we can overload properties on async, then we could make the async result return Task instead which would be nice.

Agreed, it will be rare, but we should support such use cases. I believe the suggestion above supports that.

nice, thanks

I don't have enough experience with such APIs to know if they are important to use "all the time except in weird cases" or "only need to be used in advanced cases". However, the backpressure version being async will force an await up the chain, making it a lot more onerous. I hope it is not the normal case.

-Chris

1 Like

There was the idea to specify (via a parameter to add/run) whether a group’s task is strong or weak as far as the termination of the group is concerned. Any plans to add that?

Ah okey, I misunderstood what you said I think. Yeah the following could be nice:

let task: Task = await Task.current
let task: Task? = Task.current
let task: CurrentUnsafeTask? = Task.currentUnsafe

I don't think we should make await Task.current return Task?, that's pretty silly -- the task is always there, so this will cause people to spray much ! onto it whenever it is used which would be unfortunate.

That could be nice, but we can't do this, we can't overload like that - I don't know if it will be supported - @Douglas_Gregor ?

Except that we can't spell this :wink:

This is meticulously designed to allow the same call to be made from an asynchronous and synchronous context. This matters a lot because I really don't want to be constantly annoyed about sometimes having to await Task.current?.isCancelled ?? false and sometimes await Task.current.isCancelled.

This is why the static functions are not async and implement all the right logic for all functionality they expose, while using the unsafe API internally.


I also don't necessarily agree that APIs must take the very explicit shape for this micro optimization. In most peoples application this will not be a dominator/problematic case.

Could we use an "withUnsafelyDeterminingTask<R>(do body: (UnsafeCurrentTask?) throws -> R) rethrows -> R?" function?

1 Like

Squeaking in under the wire for comments on this proposal. Structured concurrency is going to be a key part of the concurrency story for Swift, and I'm excited to see it come to the review stage.

Overall, I worry that the proposal--both text and underlying design being described by the text--is not as straightforward as it could be for such a key concept. I have read this proposal several times, and nothing compares to the clarity or lightbulb moment I had when reading Notes on structured concurrency, which distilled the entire concept of structured concurrency to the following:

Structured concurrency is to go statements (or, spawning a new thread or task) as control flow statements are to goto statements.

While concurrency itself is necessarily hard, the concept enabled here is not. Even as it's been said that these facilities introduced here are to be low-level primitives on top of which more "sugary" syntax can be provided, I think it's still very important to work towards as much clarity as possible here: users should be able to hold in their minds a good understanding of the basics and not have to rely on the more "sugary" syntax just to know what's going on.

I'm going to split my comments into several consecutive posts. The first will be a series of notes on clarity of writing. I've approached my reading of the proposal afresh from the perspective that it needs to be able to function serviceably as an expository text on the feature, and I believe strongly that clarity of writing will help promote clarity of thinking, and in turn clarity of design. Following that, I will try to outline some thoughts on the underlying design being described by the text.

9 Likes

Clarity-of-writing suggestions

A suspended task has more work to do but is not currently running.

  • It may be schedulable, meaning that it’s ready to run and is just waiting for the system to instruct a thread to begin executing it,
  • or it may be waiting on some external event before it can become schedulable.

The terminology is misinterpretable (note the use of the word “waiting” to describe both statuses). It would be ideal if we found some more intuitive dichotomy here.

Note that, when an asynchronous function calls another asynchronous function, we say that the calling function is suspended, but that doesn’t mean the entire task is suspended. From the perspective of the function, it is suspended, waiting for the call to return. From the perspective of the task, it may have continued running in the callee, or it may have been suspended in order to, say, change to a different execution context.

The “it” in the last two sentences refer to different things and are confusable. It is unclear what benefit there is to describing “perspectives" as opposed to writing: "The function is suspended, waiting for the call to return, whereas the task may have continued running in the callee, or..."

An asynchronous function that is currently running always knows the executor that it's running on.

“Knows” is confusing here because a function both certainly cannot "know" anything in the literal sense and also, in Swift, is not a data type, nor can it be extended to have members that could be called to return the value of something it's said to "know." Some clarification as to the nature of the knowing and what, specifically, knows (presumably “the system” and not exactly “the function”).

In some situations the priority of a task must be escalated in order to avoid a priority inversion:

  • If a task is running on behalf of an actor, and a higher-priority task is enqueued on the actor, the task may temporarily run at the priority of the higher-priority task. This does not affect child tasks or the reported priority; it is a property of the thread running the task, not the task itself.

The last clause is on its face contradictory with the preceding sentence. If the priority of a task can be escalated, then the priority can't not be a property of the task itself. That text is not necessary to make the point that the reported priority of the task doesn’t match the actual priority in that circumstance, which is plenty clear from the preceding text, and it’s unclear where the thread-but-not-the-task language comes into play.

It is possible to get a Task out of an UnsafeCurrentTask

This partial or complete sentence ends the section without punctuation. Is it intentional to leave this here? In what way is it possible, and what is the use case for it?

Task priorities are set on task creation (e.g., Task.runDetached or Task.Group.add) and can be escalated later, e.g., if a higher-priority task waits on the task handle of a lower-priority task.

This text is reduplicated.

Getting the handle's task allows us to check if the work we're about to wait on perhaps was already cancelled (by calling handle.task.isCancelled), or query at what priority the task is executing.

The cancellation example does not make sense as a justification for task: a prior paragraph notes that handle.isCancelled is a way to determine if a task has been canceled, so why handle.task.isCancelled here?

This ties into an overarching design question--more later on this.

For tasks that want to react immediately to cancellation (rather than, say, waiting until a cancellation error propagates upward), one can install a cancellation handler:

This could use some clarification: Does this API create a child task, install a cancellation handler on the current task, or create a detached task?

While all APIs defined on Task so far have been instance functions and properties, this implies that in order to query them one first would have to obtain a Task object.

By this point in the text, if I recall correctly, actually very few have been instance functions and properties; many have been static functions and properties, raising the question naturally in the reader's mind of why a Task object needs to be obtainable at all.

With this pattern, if a single task throws an error, the error will be propagated out of the body function and the task group itself. To handle errors from individual tasks, one can use a do-catch block or the nextResult() method.

Please provide an example to illustrate where one might need one or the other behavior.

8 Likes

Design comments

I do want to echo the overall feeling that @kiel expresses above:

I have seen this proposal go through multiple revisions to work through subtle issues related to ergonomics, but I can't shake the overall impression that the end result is more complex than it needs to be. I worry that it's not merely an aesthetic issue but ultimately a usability issue, where we will have solved many sophisticated pain points in very targeted ways, but with an end result that is substantially harder for users coming to the language.

To recap a bit, I'm coming at this from the perspective that, while concurrency is hard, the concept of structured concurrency ("it provides primitives with more structure than just spawning a new thread, like control flow statements provide more structure than goto statements; in so doing it helps to reduce a whole slew of bugs") is easy to understand, and we ought to be able to present some of that simplicity to users in the resulting API.

Task

I notice that Task has undergone several revisions, and in particular since the second pitch, instances of Task are now available--even though storing it for too long is not advised. Once I found the post explaining why this change was being made, I could sympathize, but the end result remains, I think, convoluted:

Near as I can tell, Task wears multiple hats:

  • It is a namespace for task-related functions that might otherwise be global functions (and indeed, the Task.with*Continuation functions were split out from this proposal and actually made global functions).
  • It functions like a stand-in for the current task in APIs such as Task.isCancelled; this design was essentially obligatory prior to the existence of instances of type Task.
  • It is an actual type, of which instances now exist, which in turn also have a substantially parallel set of instance method APIs--and not by accident but for reasons that are explainable.

I am not aware of any other type which has this kind of design in Swift, and again I do get the motivation for how it got there.

However, the practical effect is that users--before they write a single line of code that makes use of structured concurrency--learn that one can check if a task is canceled, for example, using static or instance methods found on Task, UnsafeCurrentTask, and/or Task.Handle. (Similarly, the text suggests that Task be Equatable for reasons seemingly redundant given that Task.Handle is already Equatable.)

I would strongly suggest jettisoning the use of Task as a namespace, and as much as possible to find a principled way to deduplicate static and instance methods that serve the sample purpose. If there are performance concerns because one or the other might be more easily optimized, I think those are properly addressed by compiler smarts rather than surfacing two different sets of APIs, which kind of solves the issue only by making it the user's problem.

UnsafeCurrentTask

The proposal states:

The UnsafeCurrentTask is purposefully named unsafe as it may expose APIs which can only be invoked safely from within task itself, and would exhibit undefined behavior if used from another task. It is therefore unsafe to store and "use later" an UnsafeCurrentTask.

Elsewhere, Swift provides with* APIs to expose something that shouldn’t be stored and “used later.” Was such a design considered instead of unsafeCurrent? Is there some consideration which prevents such a design here?

Task.Handle

I understand the impetus behind a design that tries not to be "future-forward" staying away from the name Future. To be clear, this is not a critique aimed at advocating for a more prominent future-based design, and I am totally supportive of the idea that the structured concurrency facilities here should not push such a future-based design.

However, even today in the proposal text itself, Task.Handle is still explicated for the user in at least one place as a future. If the easiest, most succinct way of teaching users about Task.Handle is to say that it's Swift's equivalent of a future (as has been described to users not merely in the proposal but also on this forum), it does not make sense to me to try to pessimize the naming of a type.

To preserve the intention of futures taking a backseat in this design, there are other tools in the API design toolbox; we can look, for example, to the precedent of Result deliberately vending a limited API for the purposes that it's meant to serve while making clear that it's not meant to be an alternative to supplant throwing functions.

For the same reason, I think the primitive Task.runDetached (as have thought others here on the forums) can have a more succinct name also. Yes, it is a primitive that we're not trying to put forward as the go-to option in concurrency (pun intended). But neither does that mean that we need to namespace and pessimize the naming of it. It would seem to me that a global function merely named run, detach, spawn, launch, or anything similar would work. If we're worried that structured concurrency facilities can't compete with that sort of name, then we should optimize the design of the rest of the facilities, not pessimize this one.

I guess the point here is that I think it would be a feature, not a bug, if someone who solely wants to spawn an unstructured concurrent job doesn't need to spell out Task: they're not making use of the structured concurrency features.

Task.Group

First, some bikeshedding comments:

I agree that Group is more fitting than Nursery, which is admittedly an odd though precedented name. However, there are some drawbacks with Group, and with the namespacing it under Task:

In general, namespacing implies a sort of "big-endian" is-a or has-a relationship -- Unicode has many Encodings, and UTF8 is one such Encoding. This isn't the relationship being described here: a Group isn't a kind of Task, and while it's true that a Task can have many Groups, the much more salient point we're emphasizing here (the "structure" in structured concurrency) is that a Group is something that has many Tasks.

I will also say that, unfortunately, a "group" lacks the the connotations of structure that we're going for. Notably, a major point of the structured concurrency feature is that subtasks aren't supposed to outlive the group; by contrast, in plain English, a group (of people, things, tasks) can be disbanded but the individual elements of that group carry on existing.

Therefore, I would suggest not namespacing task groups under Task, and I would look toward a name that could perhaps capture the underlying point about its role as an aggregate of tasks that cannot outlive the group: something like Agenda, Kanban, etc.


Now, returning to @kiel's thought about the use sites which are at the heart of this proposal: creating new groups and adding subtasks. Whatever the shortcomings of going literally with @kiel's illustrative code, I think the underlying impetus for simplification should be considered seriously:

let (vegetables, meat, oven) = Task {
        Task { try await chopVegetables() }
        Task { try await marinateMeat() }
        Task { try await preheatOven(temperature: 350) }
    }

By contrast, the proposal proposes a withGroup design, stating:

Task.Group has no public initializers; instead, an instance of Task.Group is passed in to the body function of withGroup. This instance should not be copied out of the body function, because doing so can break the child task structure.

My question here would be based on our prior design of Task: Why provide an instance at all? Why not emulate the Task APIs in providing static APIs, which might in turn call an “unsafe current task group” internal API underneath? Then, group.add could be Group.add (or, if we go with detach for Task.runDetached, perhaps this could just be named attach), and there would be nothing that relies on developer discipline not to copy an instance of Group that shouldn't be copied.

I'd also like to point out some nits regarding details of the task group design:

By default, the task group will schedule child tasks added to the group on the default global concurrent executor. The startingChildTasksOn argument can be provided to override this behavior.

Why does this API need to differ from the preceding API in the text, clarifying that it’s child tasks that are started on an executor, rather than just spelling the argument label just startingOn? Can anything but a task be scheduled on an executor, and could the task group be scheduling anything but its child tasks?

The add operation returns true to indicate that the task was added to the group, and false otherwise. The only circumstance under which the task will not be added to the group is when the task group has been cancelled; see the section on task group cancellation for more information.

What is the rationale for returning true or false (an unusual way to signal failure in Swift) versus, say, not returning a value but throwing CancellationError?

Miscellaneous

Longstanding convention in U.S. English, and specifically observed in past Apple style guides, is that the word is spelled "canceled" with one "l" (while "cancellation" is always spelled with two).

I understand there was some attempt to implement this in the APIs here, but it was reverted pending further discussion on review. I think we need to establish a convention in Swift (perhaps it could be added to the API naming guidelines) of whether to observe or deviate from this, and then we need to stick to it.

Finally, there appears to be some inconsistent use of the word run versus operation for argument labels for closures; it's not a big deal, but I would imagine the goal here is to be consistent. Although it probably will not be spelled out very often, I would still think run, being more concise and evocative, would be the preferred here.

18 Likes

Task.new {} is a better option than Task.runDetached {}, it means create a new Task which is different than current running one and also not a child task confined in.

2 Likes

Nice, thanks everyone for the good round of feedback :+1:

I'll try to address the main ideas:

I don't think that was seriously considered to be honest, but yeah it could be a way to go. Depends on the core team what they'll prefer here.

A bit wording nitpicking but okey I can wordsmith this more.

"The async function always knows" is a shortcut for "an async function is guaranteed to be able to obtain an Executor reference by some internal means, unlike a synchronous function which may or may not be running within an asynchronous context and be able to get such executor (or Task)".

I'll wordsmith this bit a little.

It's a missing . here.

As a matter of composability; since the type Task exists, and some API may want it, and I have obtained it using the unsafe API, I should absolutely be able to invoke some function that made the decision to accept a Task. Banning this would just invite for trouble and make Task less of a first class citizen. Such function is probably pretty silly and one should not really write such, but it is safe and legal to declare, so we must play well with being able to declare and invoke them when it makes sense, thus the UnsafeCurrentTask -> Task conversion is important (besides being trivial).

That's an entire discussion by itself, if that instance is needed or not, but I think we've argued long enough that yes, but very rarely the task instance may be useful – I can reiterate here again, but I see you agree with it in other comments.

I suspect a search/replace happened here... Yeah the recommended API here is meant to be handle.isCancelled - fixed.

The handle.task was one of those that people asked for because "why not, and it's trivial to have" since the handle keeps the task anyway. I can imagine some use cases using the Task as a key in some datastructure etc, but yeah it's just future proofing.

It does not launch / spawn new task. Only group.add and Task.runDetached are ways to spawn tasks today. Hopefully we can adjust those names a little, no-one loves the group.add for example -- maybe we'll end up with spawn as the word? For spawning tasks :slight_smile:

I'll clarify in the proposal. You can notice that by the operation not being concurrent nor escaping, but yes we should spell it out.

I mean, this is very specifically talking about the instance functions defined so far (priority, checkCancellation, isCancelled) and explains how the unsafe task API enables the safe static usable from any context counterparts for them.

The word "all" is incorrect but the sentence and premise is correct: up until this point in the proposal there was no defined way in the detailed design to call those APIs from a synchronous function, thus we introduce the usage of the unsafe API to implement the safe static counterparts.

I'll improve the wording a little bit, but the structure seems right to me.

Sure I can add a small note; though I don't think we should have to go in depth to designing all kinds of concurrency operators here; One example would be a form of "gather first 2 successful results out of those 5 child tasks; regardless if any other tasks fail." I'll add some wording on it, including the snippet:

func gather(first m: Int, of work: [Work]) async throws -> [WorkResult] { 
  assert(m <= work.count) 
  
  return Task.withGroup(resultType: WorkResult.self) { group in 
    for w in work { 
      await group.add { await w.doIt() } // spawn child tasks to perform the work
    }  
    
    var results: [WorkResult] = []
    while results.count <= m { 
      switch try await group.nextResult() { 
      case nil:             return results
      case .success(let r): results.append(r)
      case .failure(let e): print("Ignore error: \(e)")
      }
    }
  }
}

PR with updates structured-concurrency: improve wording and add examples by ktoso · Pull Request #1305 · apple/swift-evolution · GitHub

1 Like

Continuing on API notes then... commenting here on behalf of myself, so others or the core team may disagree – we'll see :slight_smile:

I hear you loud and clear on this one. It's really too bad that async let and anything with "nicer to look at syntax" was decided to be punted out of this proposal and into a future one; as really the groups API here is really not what most people will be interacting with at all on a daily basis, unless building your own concurrency "operator" like a scatter-gather or "race" or similar.

So I feel a bit "hands tied" on that – as we said we'll leave the nice syntax and niceties to a future proposal, but let's go through one by one:


Right, yes those did become top level, though mostly due to a lot of prior art with the various withUnsafe... functions that already exist and these fitting the same style. Nor are they really about Task (much less than all the other ones in this proposal).

I feel this one really boils down to personal style and opinions a lot... As a person I do like the Task namespace a lot, but I'm just one of the authors and by far not the one with most influence on it :wink: I'll leave that up to the core team to decide as it's a general Swift library style guideline thing.

The reason the namespace is nice is because it's easy to discover similar APIs I might need - e.g. this way I might learn about Task.local etc.


I don't think there's any technical reason other than "yet again more nesting".
Usages of this are likely to be deep in framework and library code so it may not be a "huge" issue.

We had hoped to have @unmovable at some point during the initial drafts... which would disallow escaping/moving the value out of the current scope, meaning that the closure would not really be necessary.

I'll leave that to @John_McCall or @Douglas_Gregor to comment if that's a likely future direction or not.


I'm not sure I agree with this; It is not only "pessimizing" the name, but also setting expectations correctly: Looking at this as a "language immigrant", coming from another language (e.g. Scala, Ruby, JS, Java, Kotlin...), or even just Swift with pervasive PromiseKit, NIO or Combine's Future types seeing a Future in Swift would trigger the "I know how to use this, map(), flatMap(), right?" and then disappointment (and maybe finally enlightenment?) :wink:

I think it is useful to call this a Task.Handle because it will not lead people into cargo culting on "okey, instead of NIO Future we pass now Swift Future" but rather lead into rethinking things a little bit more -- one would hope.

Maybe there's a middle ground here?

I–personally–would for example like if we took two verbs and hyper focused on using them throughout the APIs, e.g.: group.spawn (child task) and Task.detach and if any other forms of child tasks were allowed to be spawned we'd also talk about them using spawn. If any other "not child" tasks were allowed to be spawned, we'd use the verb "detach".

I would definitely not want to use Task.launch or Task.new as the detach operation. It looks too inconspicuous but is actually a disaster for priority and tracing propagation. So it has to "shout" that "I am going to abandon all information from my parent task!!!"


As I said before, this really feels like an opinion / personal preference thing to be honest, so let's see what the core team comes up with. Yeah it could be either Task.Group or TaskGroup doesn't really matter.

I will say however that there definitely will be new "concurrency container thingy" types in the future. So whatever we do here affects future APIs like Task.DetachedLifecycleAttachedToClassContainer or similar types.


I believe Joe's idea here was to spell out that it is not the group that will run on any other executor, which the following spelling might imply to the reader: withGroup(startingOn: coolExecutor) vs. the boringly explicit withGroup(startingChildTasksOn: coolExecutor). We had a number of people be confused if the group itself is a task or not etc. (in fact, it used to be a child task, but isn't anymore).

The actual proper solution IMHO would be soft-faults here...

I've been hoping for those for a long long time already: [stdlib] Cleanup callback for fatal Swift errors - #4 by ktoso

The lack of panics or similar mechanisms in Swift and the very explicit nature of Errors forces us into these weird API decisions... Specifically this is about: It is not the operation that might "fail", it is the infrastructure that might. The same appears in networking, or other panics vs errors situations...

But we're not going to get panics / soft faults any time soon I guess. So this design is being forced to think about -- is this automatic cancellation case worth the syntactic noise of having to try every single group.add even though the vast majority of time those will never throw...?


Hah yes, there was a PR and was reverted... :slight_smile: There seems to be both prior art for disregarding the language rules in Apple APIs e.g. NSOperation (regardless of it being wrong linguistically...). Also some people expressed that they feel super strongly about it, so indeed the current and expected spelling is cancel, cancelled, cancellation - as weird as it it.

3 Likes

Thank you @xwu for writing this. This captures my general feeling about this proposal much better than I could have. :clap:

I too feel like I understand the general design direction ("structured concurrency is to threads what structured programming was to goto"), and the high-level conceptual model ("a hierarchical tree of tasks where all child tasks of a task must be completed before that task can complete"). But when I read the detailed design and the proposed API, that understanding falls apart. Is Group a type of Task? Can tasks have children that aren’t in a Group? If I need to add the child tasks to the group, how do I add the group to the current task?

I can’t speak for @xwu, but for me this is not a question about "nicer to look at syntax". This is a question of the API not expressing a clear conceptual model that is easy to understand. async let will not make the conceptual model clearer – at best it will hide it so that I don’t have to think about it. (That might also be a worthwhile goal, but it’s a different goal.)

(Edit: I actually had an even harder time understanding the proposal before async let was removed from it.)

7 Likes