SE-0304 (3rd review): Structured Concurrency

I remember someone already mentioned that having "child" may not be appropriate (due to children in the common sense outliving their parents in general). Could the term subtask be considered instead? The sub/super relation is already present in the language. From the mathematical standpoint alone, it reminds to sub/supersets, which is appropriate in this context: a subset cannot overrun its superset as much a subtask cannot overrun its supertask.
It's also apt from the common parlance point of view: if you mark a task consisting of various subtasks as completed, that means that you've generally completed/handled all said subtasks.

3 Likes

"Child", "parent" (and sometimes "leaf") nodes are incredibly common vocabulary in threading/concurrency concepts (processes, actors), and also just plain old tree data-structured which is exactly what is modeled by these here. I really don't think revisiting the names of child task and parent task is necessary. They are vocabulary only and not API per se, and that's IMHO totally fine.

5 Likes

Overall, I find this iteration of the proposal to be the best yet. The problem is certainly important and the API has evolved nicely. I do not have as much hands-on experience with concurrency features in other languages as I'd like, but I've read about a fair number of them, and I've thought about this proposal carefully through its various iterations, hasty though this review will be in the writing of it.

Now, to the details:

I'm glad I procrastinated in writing, because @Chris_Lattner3 has pointed out several issues in a more articulate way than I could, which I wanted to speak on as well:

  • I am glad that there is striving for consistency, but in standardizing on async we've got some odd phraseology because "async" is fundamentally an adjective or adverb, and that paints us into awkward situations. If we are to strive for consistency, I think it's important that some of the task-group-based spawning APIs and non-task-group-based spawning APIs be more harmonized too.

  • This proposal is clearly avoiding an API with the term "future," but every iteration of this proposal has had a future-like type. I understand that the whole point of this structured concurrency idea is to avoid a future that can be passed around willy-nilly, but it does feel somewhat like we've now got a type-that-shall-not-be-named, and the overall design seems to be under strain in order to accommodate that. (Sticking to this analogy, in this version of structured concurrency we find that Task is now Professor Quirrell, with the type-that-shall-not-be-named on the back of its head.) I think @Chris_Lattner3's exploration of the issue is a persuasive one.

As to the naming of things--

  • I think it ought not to be rejected out of hand the objection that Swift is establishing a design where "children" must not outlive their "parents." I do not think this is frivolous. Sure, the term "child" is incredibly common in many technical contexts, but in most cases the thing termed a "child" does not have a lifetime that is constrained to be shorter than that of its parent as a desideratum. This is just an incredibly sad way to phrase something that doesn't need to be expressed with such emotional valence, particularly since it's actually incredibly exciting that we're going to be able to use this property to improve the correctness of the code we write: we must remember that we are speaking to human beings about this feature.

  • It seems we have settled on a design in which one task has many jobs. In the ordinary world, typically a person has one job but many tasks. Can we find another way to describe a quantum of schedulable work that might more intuitively describe its relationship to a task?

And finally...

  • I raised this in an earlier review or pitch feedback, but the point has not been addressed by way of explanation or correction. Standard U.S. English spelling is "canceled," and Swift standard library APIs have always adhered to this Websterian convention (for example: isSignalingNaN, not isSignallingNaN). I just checked again, and Apple still has a style guide, which says just as it has for decades*:

    canceled (v.), canceling (v.), cancellation (n.)
    Use one l for the verb cancel—for example canceled, canceling. Use two l’s for the noun cancellation.

    If there's a rationale for deviating from this, the authors should explain why so that the community and core team can evaluate the reason. Otherwise, we'll inevitably have the scenario where first-party documentation for the API will read something like: "isCancelled—A Boolean value indicating whether the current task is canceled." And we'll run into clashes where one moment we're cancelling and the next moment we're signaling.

* FWIW, it’s not just dictionaries and technical documentation that adhere to this rule. Consider, for example, this educational dialogue from the hit 2000s TV series The OC, season 2 episode 3 (penultimate scene):

RYAN: Oh, well, um, next time, don't spell “canceling” with two l's. Yeah, that's wrong. You wanna—you wanna fix that?

LINDSAY: I—I was using the Canadian spelling.

RYAN (Canadian accent): Oh, you were usin’ the Canadian spelling, eh?

12 Likes

I’m concerned that the spelling Task { ... } is too convenient. It lends itself to thinking of Task { ... } as the “simple” case and group.async as an “advanced” case, as seen here:

In its simplest form, you can start concurrent work by creating a new Task object and passing it the operation you want to run.

For more complex work, you should create task groups instead – collections of tasks that work together to produce a finished value.

With a spelling as simple and attractive as Task { ... }, it seems hard not to present things this way, unless you’re deeply invested in advocating a structured-first approach. If we want structured concurrency to be the go-to choice, I think there needs to be at least a slight road bump here.

(I have a feeling “why not just use Task { ... } everywhere?” will be the new “why not use [weak self] everywhere?”)

6 Likes

While I agree there's a risk of Task over-use, "let's make this common need awkward to use so people don't use it incorrectly" is generally not a good solution. Rather, it's better to make doing the right thing in those circumstances easy too, which is what the async let proposal is for.

7 Likes

+1 for "redefining" the use of Future to mean something that will not outlive its source :+1: :slight_smile:

2 Likes

So, I may be wrong, but the Platform State of the Union just talked about how great Structured Concurrency is… but it hasn’t even been accepted or implemented into Swift 5.5 What am I missing, was a decision announced on this proposal?

This is the third round of review. The basic design of the proposal has been broadly accepted by the community, and we're now debating a few largely superficial details. Those details are important, but no matter how they're decided, it's no longer in question that Swift will incorporate some form of structured concurrency around tasks.

We currently expect that Swift 5.5 will provide whatever design is accepted here. Indeed, the underlying implementation is already in place, and it's mostly just the API design that's changing.

15 Likes

Yes, and there's some rationale over in that thread about centralizing around async for structured concurrency. Yes, async is an adjective/adverb in English, but Dispatch has set a very strong precedent for using async to initiate new asynchronous work.

We can certainly clean this up.

A task has a single starting point and returns a single value, which might be a value or a thrown error. Any asynchronous calls the task does along the way don't create new tasks, they're just part of the same task.

It could be separated out, but it's only worthwhile if we think there's going to be significant revision. Doing sleep really well requires a type to describe time properly, which we don't yet have and is a big undertaking in and of itself. Yet Task.sleep is an important operation, hence my desire to get it the slightly-uglier name Task.sleep(nanoseconds:) and leave the time-type design (and nicer name Task.sleep(_:) for later.

Doug

5 Likes

Ok, but dispatch and its APIs be effectively gone (replaced by this new thing) from the nomenclature of Swift in a few years. This isn't an industry term of art that you're aligning with. I don't see how this is very strong rationale, it seems like we should fix the mistake of the past.

Also, it doesn't align with other uses of async in Swift which is very big deal as pointed out by many on this thread. async means "this is suspendable" not "create a new task".

Detatch, Task groups and the proposed 'async let' thing (however it is spelled) all create new tasks, which all produce asynchronous results. It is entirely reasonable to want to describe a function that returns multiple results that are resolvable at non-determinstic time with respect to each other (e.g. they are coming from two different remote machines). In other systems you typically spell this with (Future<..>, Future<..>) (where the outer parens are a tuple.

Sure, agreed.

-Chris

3 Likes

I like your optimism, and while I don't expect that the timeline will be so short: point taken.

So, you can take the structured approach if you want both values resolved together:

async let a = thing1()
async let b = thing2()
return await (a, b)

or you can use an unstructured approach if you want to make them separately resolvable:

let aTask = Task { await thing1() }
let bTask = Task { await thing2() }
return (aTask, bTask)

The async is keeping you in the structured world. An async let is tracking the task that you'll need to await to get the value. The naming is emphasizing the split between structured and unstructured.

Doug

Maybe is a bit off topic, but Joe recommend to raise it on the forums:

right now sleep doesn’t check at all for cancellation, making some use cases of structured concurrency await longer than needed.

Would it be possible to at least implement some eager cancellation before this big undertaking for a time type?

2 Likes

I agree there should be a way to make sleep() be able to be cancelled. Were you thinking that it should check at the beginning, or return early?

IMO it should return early, as soon as possible. Somewhat like when urlsession new async functions get cancelled and stop early. Of course I have no clue how complex that is to implement ^^’

Although it may be a little late to join the discussion, but I found the following code (from WWDC session) rather confusing to me:

async { await self.healthKitController.save(drink: drink) }

As other community members have pointed out, the first async keyword here has totally different meaning from the async in async/await proposal. I believe the code above will be common when people adapt existing code bases, which may cause further misunderstanding when we discuss things in terms of async and await.

I understand async may be an easy term to memorize and use, but clarity should not be sacrificed when choosing a keyword which will be used in common concurrent scenarios. When talking about async I'd prefer the single case that functions have the capability to suspend.

Some may argue that we use DispatchQueue.async already, but it's a library feature instead of language feature. I suppose we should have a much higher bar for naming in language features.

1 Like

FWIW, the latest version of the proposal changes the spelling of async { ... } to Task { ... }, but this didn't make it for the beta 1 seed build.

7 Likes

As a meta-review, I really appreciate the detailed change-log. It really helps catching up to the current state of the proposal, especially if you got off midway somewhere.

3 Likes

I I've been brooding over this for a while, and I still think that TaskGroup should cancels all of it's child tasks at the end of the body regardless of how it finishes (throwing vs non-throwing).

Firstly, note that we can override the default behaviour by calling group.cancellAll() or while true { await group.next() }, so it's more of the preference and has no impact on the expressibility.

There are a few reasons I prefer cancellation for all exits, even if the body doesn't throw.

  • It makes the system much easier to explain (unused child tasks are cancelled).

  • It would help the task group finishes earlier if the child tasks are cooperative, which is roughy the point of the cancellation.

  • Furthermore, I believe it's more common for unused child tasks to be easily discardable and has no side effect.

    Again, if it is important that the task finishes, it's more likely that async {} or Task.detach are better for such jobs.

4 Likes

Review Continuation

This proposal has been revised and a fourth review has been initiated.

2 Likes