[Accepted with modifications] SE-0304: Structured Concurrency

The fourth review of SE-0304: Structured Concurrency has ended and the proposal has been accepted with modifications.

Much of the fourth revision was about naming and the community was broadly positive about these changes, with discussion focused on a few specific points.

The naming of the function suspend, that frees the task mid-function, was discussed. The core team agrees that the originally-proposed yield is a better name. The risk of confusion with the potential future use of yield in modify accessors is low, and the word yield better describes what the function does.

It was noted in review that UnsafeTask was missing a cancel method. This will be added on acceptance.

The ability to omit self on task closures was discussed. Currently this is achieved through an implementation detail using underscored attributes. A similar approach appears for marker protocols in SE-0302: Sendable and @Sendable closures. In both cases, the core team is interested in exploring ways in which these capabilities could be generalized into public language features in the future, without feeling this needs to be done prior to accepting this proposal.

Regarding Task.detached { } versus Task(.detached) { } , the core team feels that while they both achieve the same goal of the effect of a label with no argument, Task.detached { } is preferable as it does not require the addition of an extra enum type purely to indicate the task should be run detached.

Finally, the naming of Task, and of the addTask method on TaskGroup, was extensively discussed. This was a tough decision for the core team to make, with differing opinions within the team. Creating a Task and launching it are intentionally linked: tasks cannot be first created and then later launched. This combination of value creation and side effect does not have much precedent within the standard library on which to base the decision, and the existing naming practices offer guidance in different directions. The practice of using an initializer to create instances (here, of the Task type), and the goal of omitting needless words, conflict with the guidance to use an active a verb that reflects the fact that creating a task also starts running that task. Prioritizing these conflicting guidelines is a matter of taste and judgement, and that naturally includes a level of subjectivity. Ultimately, the core team decided to accept the naming as proposed.

Thank you to everyone who participated in the review!

39 Likes

Though this proposal is already accepted by the core team, I would still like to understand the rationale behind the decision. In the quoted paragraph above the only reason I see to support the definition of Task { ... } is "tasks cannot be first created and then later launched", but this reason even fits better (IMO) to the Task.run { ... }. And all the other points in this paragraph are actually against the Task { ... } definition. Can the core team explain further why we finally choose Task { ... } over Task.run { ... }?

There is also some discussion about moving methods related to the current task under a specific namespace, e.g. Task.Current.isCancelled (or Task.current.isCancelled). But the conclusion of this proposal doesn't mention this. Is there any reason why the core team decided to keep the current design?

cc @Ben_Cohen

1 Like

Task.run is not an usual syntax to create a new object and make not clear a new Task is created.

Task {} is a usual constructor syntax and make it clear a new Task is created, but not clear that the Task is started too.

There is no good and bad choice here, just a matter of taste, so it will be hard for the Core Team to give a better rational than what they already gave in the previous message.

2 Likes

I see in Cocoa there have already been cases like NSAnimationContext.runAnimationGroup. I agree that run doesn't make clear a new Task is created, but we can also use Task.launch or Task.start. It's just a choice of a verb I suppose.

In Context Inheritance, the proposal says:

If called from a context that is not running inside a task:

• execute on the global concurrent executor and be non-isolated with regard to any actor.

However, later in the detailed design's Actor Context Propogation, it gives the example:

func notOnActor(_: @Sendable () async -> Void) { }

actor A {
  func f() {
    notOnActor {
      await g() // must call g asynchronously, because it's a @Sendable closure
    }
    Task {
      g() // okay to call g synchronously, even though it's @Sendable
    }
  }
  
  func g() { }
}

In the new task, it is okay to call g(), which must be because it is in an actor context—but that task is presumably a top-level task that is not running inside a parent task.

So I read an inconsistency. Should top-level tasks be non-isolated with regard to any actor, or not?

No:

if executed within the scope of a specific actor function:

  • inherit the actor's execution context and run the task on its executor, rather than the global concurrent one,
  • the closure passed to Task {} becomes actor-isolated to that actor, allowing access to the actor-isolated state, including mutable properties and non-sendable values

You sure?

If called from the context of an existing task:

  • inherit the priority of the current task the synchronous function is executing on
  • inherit all task-local values by copying them to the new unstructured task
  • if executed within the scope of a specific actor function:
    • inherit the actor's execution context and run the task on its executor, rather than the global concurrent one,
    • the closure passed to Task {} becomes actor-isolated to that actor, allowing access to the actor-isolated state, including mutable properties and non-sendable values.

(emphasis added)

The constructor (and its new task) is presumably not being called within the context of an existing task, so that text would not be relevant.

Yes, I’m sure.