[Pitch] Usability of global-actor-isolated types

Today, there's lots of code out there that, necessarily, looks like this:

let closure: @MainActor @Sendable () -> Void = {}

With this proposal, the inclusion of @Sendable is redundant. But that also makes it quite confusing. Should this now produce a warning? Should it be allowed at all?

2 Likes

I think we should produce a warning with a fix-it since the code is technically correct. We also have other instances in the compiler already where we diagnose redundant attributes with a warning and provide a fix-it (like redundant (unsafe))

I think this case is similar, and this approach makes sense for people just starting to learn concurrency. What are your reasons behind potentially disallowing this?

An instance of an isolated subclass is tracked as a single value in region isolation. The superclass can have nonisolated methods that return its non-Sendable state, which the subclass inherits. If the subclass is Sendable, that would mean those values are in a disconnected region if accessed. If the subclass is not Sendable, the values will be in the same region as the subclass value, because arguments and results are assumed to be in the same region unless otherwise annotated.

Sure:

protocol P { ... } // does not refine `Sendable`

struct S: P { // implicitly `Sendable`
  var value = 0
}

func generic<T: P>(_ t: T) async { ... }

@MainActor func useGeneric(s: S) async {
  await generic(s) // okay
}

In the above code, P does not refine Sendable, and the function generic accepts an argument that conforms to P. Calling the function from an actor-isolated context will cross an isolation boundary. At the call-site, we're passing an argument value s that's a parameter to useGeneric, meaning it's possible that the value bound to s will be accessed concurrently from both the main actor and the generic executor. But that's okay, because the type of s is Sendable; the concurrent access cannot introduce data races.

It sounds to me like you're describing a very different concurrency model to the one we have in Swift. There is no such thing as a generic function requiring that an argument be isolated to the calling context, and I do not think we would accept such a rule in the Swift concurrency model because it's crucial that adding a Sendable conformance lifts limitations rather than imposing them. Library authors rely on the fact that adding a Sendable conformance should not break clients; it's an important property for incrementally adding concurrency annotations across the Swift ecosystem.

The tradeoff is exactly what you wrote in your comment. Your suggested rule is imposing a limitation that this isolated subclass which has a conformance to Sendable cannot be passed to a synchronous generic function.

I remain convinced that the rules in the proposal are a better fit for Swift's concurrency model and the set of necessary restrictions are easier to understand. I am not going to pursue your suggested rule in this proposal. Like John said, there's nothing in this proposal that would prevent exploring ways to allow a safe Sendable conformance on the isolated subclass in the future.

It depends on the context of those two variables. Is that in top-level main.swift code, or is that meant to be in the body of a function so the variables are local variables? If they're meant to be local variables of a function, then d and b are in a disconnected region together. Note that Derived.init is nonisolated per SE-0411:

If none of the type's stored properties are non-Sendable and actor isolated, and none of the default initializer expressions require actor isolation, then the compiler-synthesized initializer is nonisolated .

No; this is stated in the proposal document:

Inherited and overridden methods still must respect the isolation of the superclass method:

class NonSendable {
  func test() { ... }
}

@MainActor
class IsolatedSubclass: NonSendable {
  var mutable = 0
  override func test() {
    super.test()
    mutable += 0 // error: Main actor-isolated property 'isolated' can not be referenced from a non-isolated context
  }
}

Yes, and the overridden initializer must have the same isolation as the superclass initializer.

1 Like

I agree with John's assessment. I'm interested in exploring how Sendable conformance can be made safe, but as this discussion has shown, it has non-trivial dependencies which need to be addressed first. In the meantime, I think allowing GAI-subclasses is still a positive change, even without Sendable conformance.

I think it would be best, if I make a separate pitch to discuss my ideas in depth there, and not to derail this topic.

Yes, I meant as local variables. But they are in a disconnected region only because Derived.init is nonisolated, right? If Derived would be less trivial, e.g. if it had some mutable stored properties, then Derived.init would be @MainActor isolated, and would return a value in the region connected to the @MainActor, so d and b would be in a connected region. Is my understanding correct?

class Base {
    var m: Int // nonisolated

    init(m: Int) { // nonisolated
        self.m = m
    }

    func inc() {
        self.m += 1
    }
}

@MainActor class Derived: Base {
    var n: Int // @MainActor-isolated

    init(m: Int, n: Int) { // @MainActor-isolated
        self.n = n
        super.init(m: m)
    }

    // @MainActor-isolated, cannot override :(
    func incDerived() {
        self.n += 1
        super.inc() // ok
    }
}

@AnotherActor
func connectToAnother(base: Base) {}

@AnotherActor
func connectToAnother(derived: Derived) {}

func test() async {
    let d = Derived()
    let b: Base = d // [{d, b, @MainActor}]
    await connectToAnother(base: b) // error
    await connectToAnother(derived: d) // error
    await d.incDerived() // ok
}

But if Derived.init() remains nonisolated, then region of d can be potentially connected to another actor, despite d being of @MainActor-isolated type. That's a very surprising state, but technically a valid one. In this state all isolated members of Derived become unusable. Is my understanding correct?

3 Likes

Yes, that's right. More specifically, if Derived has any properties that either:

  1. Have non-Sendable type
  2. Have an initial value that is isolated to @MainActor

Then Derived.init must be @MainActor-isolated (and the compiler-synthesized initializer will have @MainActor). This means that the initializer will return a value in the main actor's region, so both d and b will be in that region like you said.

Your understanding matches mine. If you have an instance of Derived in any isolation domain other than the main actor, then Derived's isolated state is not usable, but the inherited, non-isolated state from Base is still usable.

It was decided to omit the above from the proposed changes, because there is no obvious use case.

However, if you have an idea of how this addition can be useful in your Swift code, please share it here!