SE-0304 (4th review): Structured Concurrency

This general problem – of being able to distinguish an initializer purely with a marker – has come up before in an evolution proposal, though unfortunately I can't recall which one. IIRC we didn't settle it into an idiom at the time, and that the "a single value enum" overload suggestion was rejected (as was the uglier "defaulted bool" i.e. Task(detached: true)).

This potential solution is well known, but types have a cost (they need to be documented, single-case enums are weird at first glance and are kind of a "trick" solution, albeit a mild one compared to other overloading tricks), and I think there needs to be more justification why Task(.detached) { ... } is better than Task.detached { ... }, beyond stylistic preference or claims of better discoverability.

8 Likes

You might be thinking of SE-0104, but that was for methods rather than initializers.

1 Like

Wait, a Task is a struct? Previously in these discussions Douglas said they have reference semantics.

How is this struct a reference type?

From the current implementation, Task is a struct that wraps a reference.

1 Like

So it technically has value semantics but that doesn’t matter because any copy of it will still have the same Handle reference?

You are right. (But I am not sure if we should call it "having value semantics"...

Somewhat of a tangent, but...

the distinction that I've seen in these forums in the past is to differentiate between "has value semantics" and "is a value type." Whether a type is a value type is relatively trivial to determine based on the category of its declaration (enum, struct, tuple, etc.), but determining whether a type has value semantics is potentially much more complicated and heavily implementation-dependent. Consider Array which also wraps a reference internally but still "has value semantics."

Of course, what it even means, precisely, to "have value semantics" isn't something that is entirely clear yet, and might not even make sense to talk about at the level of a type as a whole, so right now we're still mostly in "know it when you see it" territory.

ETA: I was too pessimistic. See Dave’s post below for more information.

4 Likes

Right, structs aren't necessarily value semantic. Two easy example are UnsafeBufferPointer and Array<YourClass>. Both are structs, but neither have what we'd traditionally consider values semantics for their "contained" values.

4 Likes

Just want to thank you all for working so hard to get this right.

I haven't been participating in this discussion, but have skimmed new content in the thread from time to time. It's clearly complicated, important, and will have a big impact on many users of Swift for years to come.

So, thank you.

10 Likes

To throw out a further thought on this, it is also confusing that the static members on Task are a third different meaning for the word: they are accessors for the current task, which is a completely different thing than instances of Task and the underlying "task".

It seems like there are actually three things going on here, and I think the first revision of the proposal was stronger because it spelled them out directly:

  1. There is the runtime object, a "task".
  2. There is TaskHandle which is the discardable result of launching a task, which allows cancelation and result collection, etc.
  3. There is Task which is a namespace (e.g. a case-less enum) for static convenience functions that allow manipulating the current task safely.

With this approach, it would make plenty of sense to have:

  // The handle is a sendable and generic value.
  struct TaskHandle<Success: Sendable, Failure: Error>: Equatable, Hashable, Sendable { ... }

  // Task is just a namespace that is never instantiated.
  enum Task {}
  public extension Task {
    @discardableResult
    static func launch<Success>(priority: TaskPriority? = nil,
                   operation: @Sendable @escaping () async -> Success
                     ) -> Task<Success, Never> { ... }
    @discardableResult
    static func launch<Success>(priority: TaskPriority? = nil,
                   operation: @Sendable @escaping () async throws -> Success
                     ) -> Task<Success, Error> { ... }

    @discardableResult
    static func launchDetached<Success>(priority: TaskPriority? = nil,
                   operation: @Sendable @escaping () async -> Success
                     ) -> Task<Success, Never> { ... }
    @discardableResult
    static func launchDetached<Success>(priority: TaskPriority? = nil,
                   operation: @Sendable @escaping () async throws -> Success
                     ) -> Task<Success, Error> { ... }
  } 

Note that this also eliminate the constraints like extension Task where Failure == Never and extension Task where Failure == Error on these members, eliminates the strange inference of "success and failure" for the other static members on Task, and eliminates the duplicate implementation of these members:

@available(SwiftStdlib 5.5, *)
extension Task {
  public var isCancelled: Bool {
    ...
  }
}
@available(SwiftStdlib 5.5, *)
extension Task where Success == Never, Failure == Never {
  public static var isCancelled: Bool {
     ..
  }
}

How do Success and Failure get inferred when you call Task.isCancelled as an expression anyway? Is there some special inference rule to avoid what the stdlib is doing with expressions like if Task<Never, Never>.isCancelled { ?

-Chris

4 Likes

I agree with the premise, but not the spelling.

The runtime object is a “task”, yes.

The result of launching a task, however, should be a Task, since it is the user-facing type which represents a task.

The namespace for operations on the current task is what I would suggest renaming. It represents the current task, so it should be called CurrentTask.

(Or perhaps even “currentTask” as an instance rather than a type, similar to newValue and super.)

4 Likes

To me, handle.isCancelled and handle.cancel() would seem wrong. It's not the handle which gets cancelled, it's the task, so I like the fact that in the proposed design what gets returned is an instance of Task<Success, Failure>.

I also wouldn't prefer the use of unconstrained static functions since we cannot annotate generic parameters explicitly when calling them. It could result in verbose call sites:

Task.launch { () -> Double in
  print(2); return 4
}
let handle = Task.launch { () -> Double in
  print(2); return 4
}

instead of

Task<Double, Never> {
  print(2); return 4
}
let task = Task<Double, Never> {
  print(2); return 4
}

[WIP][TypeChecker] Incremental multi-statement closure type-checking by xedin · Pull Request #38577 · apple/swift · GitHub would help a lot in most cases, but wouldn't be able to cover everything.

2 Likes

Just my guess (from the authors' perspective): today in Foundation we can access the current thread via Thread.current. But while users can call methods with side effects (like cancel(), exit(), sleep(until:)) on Thread.current from the current execution context, these "mutating" interfaces are not expected to be accessed from Task.current. To provide a safer API, the authors want to give a read-only view for the current task. Unfortunately, if we want to support syntax like Task.current.isCancelled in Swift, the user has the ability to store Task.current to a variable which may escape from the current call. So there will either crash or undefined behavior in this case, or we need to introduce something new in the language to forbid the result from Task.current escaping the current call. With this rationale the authors choose to move the APIs related to the current task under Task namespace directly.

Separating TaskHandle from Task so that Task only serves as pure namespace looks reasonable. But even with this improvement, we still have Task.launch(...) and Task.isCancelled under the same namespace. This does not align perfectly with the statement that "There is Task which is a namespace (e.g. a case-less enum) for static convenience functions that allow manipulating the current task safely.". The "static convenience functions" part is okay, but "manipulating the current task" seems some implicitness to me. And I wonder if some user would assume that Task.launch(...) would create a task that is a child from the current task (under that statement)?

I suggest maybe we could create another namespace under Task, like Current, so that we have the following interfaces:

struct TaskHandle<Success: Sendable, Failure: Error>: ...

enum Task {}

extension Task {
  public enum Current {
    // We can put all the convenience functions related to the current task under this namespace
    public static var isCancelled: Bool { ... }
  }
}

public extension Task {
    static func launch(...)
    static func launchDetached(...)
}

So we can use something similar to Thread API like Task.Current.isCancelled. We can also shorten the statement 3) to "There is Task which is a namespace (e.g. a case-less enum) for static convenience functions that allow launching unstructured tasks".

1 Like

Or we could have a Task.current property that would return an instance of an empty type Task.Current. And while you probably shouldn't, if you pass that instance around and call its isCancelled it's still clearly about the current task at the moment you make the call because the type is Task.Current. No crash or undefined behavior.

extension Task {
    struct Current {
        // no stored properties
        var isCancelled: Bool { get }
        var priority: TaskPriority { get }
    }
    static var current: Current { get }
}

And so the call site becomes:

Task.current.isCancelled
Task.current.priority
// vs.
Task.isCancelled
Task.currentPriority

I'm not sure which one is better when it comes to isCancelled, but when it comes to priority my preference goes to the first one.

This was the API many revisions ago. It is much worse than the focus on static functions to query the current tasks status:

  • lack of static functions and relying on Task.current means it would have to be Task.current?.priority
  • the static functions are “smarter”, if no task is detected, they query the current thread for its priority; this leads to more easily correct code, than having developers always do this “oh and if there’s no task, do this extra dance”

It also causes us to have 2 (or 3) ways to query the the same information — the existence of Task.Current does not remove the need for Task.withUnsafeCurrent { … } so that’d still be a thing. If we keep static functions, we’d have 3 ways to query the same information.

——

But the actual reason why this can’t be the API is:

Today, there’s no way we can make Task.Current safe.

It would have to be some property of the variable that we can never store it anywhere. As otherwise we may point at an already dead task, and it causes all kinds of hell for the structured nature and lifetimes of tasks.

I think you misunderstood my suggestion. Task.current does not return an optional, and Task.Current is an empty shell that just forwards to today's static functions.

Implemented on top of today's proposed API it'd look like this:

extension Task {
    struct Current {
        // no stored properties
        fileprivate init() {}
        var isCancelled: Bool { Task.isCancelled }
        var priority: TaskPriority { Task.currentPriority }
    }
    static var current: Current { Current() }
}

I guess it formalizes the idea that there's "always" a current task, even when not really inside a task. To me, this is what we're already doing with Task.isCancelled and Task.currentPriority not returning optionals.

3 Likes

Could we change Task.Current to be a stateless enum and move isCancelled and priority to type-level properties? (Both Task.Current.init and Task.current would be removed.)

Adding some thoughts on the difference between these two words:

At first I was thinking about the idea that "launch" references the initial moment whereas "run" references the entire execution. With that in my I began to consider possible problems in which the word "run" could feel like it implies the full completed running of the task, which if it throws is not a known or guaranteed outcome at the moment of initiation. I began to doubt some of the reasoning here though.

A perhaps more refined way to consider the difference is that to "launch" something is to give it an initial impulse but then cease to influence it, whereas to "run" it is to accompany it all along the way lending it energy the whole time. How to actually apply these contrasting definitions I've just offered in the context of this discussion in order to arrive at an agreed-upon word? I feel ill-equipped to say, because I haven't thoroughly read and understood the proposal. But I do think that this distinction could be of use when crafting a rationale for the final choice.

At the moment my instinct leans strongly towards "launch", for what it's worth.

FWIW, I disagree. IMO this is clearly defined and makes sense at the level of a type as a whole.

2 Likes

Thanks for the callout Dave—I agree your definition is a solid one and I’ve edited that post to be more charitable. I really only meant to highlight the fact that the formalization of value semantics was a topic of recent debate without an officially adopted definition by the Swift project at large.

Terms of Service

Privacy Policy

Cookie Policy