[Pitch] Usability of global-actor-isolated types

Hello, Swift Evolution!

Together with @hborla and @mattie, I have drafted a proposal encompassing a collection of changes to concurrency rules concerning global-actor-isolated types to improve their usability.

Global actor-isolated types have several properties that we can use to improve the existing concurrency rules.

We propose the following improvements for rules around global actor-isolated types:

  • Treat Sendable properties of a global-actor-isolated value type as nonisolated within the module.

  • Infer the @Sendable attribute for global-actor-isolated functions and closures.

  • Allow globally isolated closures to capture non-Sendable values.

  • Require the global-actor-isolated subclass of a nonisolated, non-Sendable to be non-Sendable.

You can view the full proposal draft here: swift-evolution/proposals/NNNN-global-actor-isolated-types-usability.md at global-actor-isolated-types-usability · simanerush/swift-evolution · GitHub

If you have editorial feedback, you're welcome to leave it on the swift-evolution PR here: Add a proposal to improve usability of global-actor-isolated types. by simanerush · Pull Request #2372 · apple/swift-evolution · GitHub

We're looking forward to hearing your questions, thoughts and feedback!

14 Likes

Hello,

Thanks for those QoL improvements!

I was expecting to see something about global-actor isolated type that define public static properties that have a Sendable types. Those are a real pain point. See https://forums.swift.org/t/difficulty-designing-a-static-requirement-due-to-sendable-se-0412/70107/29:

ADDENDUM:

I can't put my finger on it now (some Mastodon thread), but it looks like there is two distinct problems with static properties of Sendable type:

@MainActor
public class MyType {
    // 1
    public static let constant = "foo"

    // 2
    public static var notConstant: String { "bar" }
}

I was referring to 1, the constant, in the beginning of this post. It should be nonisolated:

@MainActor
public class MyType {
    // The CORRECT (i.e. not painful for users) declaration
    public nonisolated static let constant = "foo"
}

On this other side, the var notConstant, as declared, is isolated, and should be, since it is declared as mutable.

But some Objective-C frameworks define constants that are imported as var. That's where I can't find the link to a recent example.

I forgot my Objc, so please excuse this gross attempt at reproducing the problem:

// That's a static property exported as `var` in Swift, right?
NS_SWIFT_UI_ACTOR
@interface MyType
@property (class, readonly) NSString *constantButExportedAsVar;
@end

@implementation MyType
@dynamic constantButExportedAsVar;
+ (NSString *)constantButExportedAsVar {
    // This is constant, but nobody knows 😖
    // Swift compiler can't suggest to make it nonisolated.
    return @"bar";
}
@end

I would expect some audit of Apple frameworks in this regard.

4 Likes

Hi all, happy to hear about these improvements to strict concurrency. One improvement that I don't see mentioned is around global actor isolated existentials.

For example the following currently generates a diagnostic:

@MainActor final class C {}
@MainActor protocol P: AnyObject {}

struct S {
  // Happy days
  static let defaultValueA: C? = nil
  // Static property 'defaultValueB' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in Swift 6
  static let defaultValueB: P? = nil
}

Is this something that could be addressed a part of this proposal?

1 Like

I thought about this more and it's not just about whether or not the property type is a let and Sendable. The initializer for the static variable must also not be global-actor-isolated. The isolated default value proposal makes decisions about the isolation of the member-wise initializer based on the isolation of stored property initial values because the member-wise initializer is always internal and it's not an API promise, but I'm strongly against applying such inference to public API when it's not explicit.

I think there are two different, subtle questions here.

  1. Can global actor isolation on a protocol imply that the protocol refines Sendable?

No, because global actor isolation on a protocol does not require the conforming type to be isolated to that global actor. Global actor isolation on a protocol does two things: 1) it applies the global actor to all protocol requirements, and 2) it's an inference source for conforming types if the conformance is stated at the primary declaration. If you add a conformance to a global actor isolated protocol via an extension outside the defining module, or even inside the defining module in another source file, the global actor attribute cannot be inferred on the entire type, so all mutable state of that type may be unprotected. For this reason, it's completely possible for global actor isolated protocols to be conformed to by non-Sendable types. The way to require Sendable is to explicitly write it in the protocol:

@MainActor protocol P: AnyObject, Sendable {}
  1. Can the unsafe static variable warning be suppressed if it's a let constant and the underlying initial value actually is Sendable?

If you wrote this:

@MainActor final class C: P {}

@MainActor protocol P: AnyObject {}

struct S {
  // Static property 'defaultValueB' is not concurrency-safe because it is not either conforming to 'Sendable' or isolated to a global actor; this is an error in the Swift 6 language mode
  static let defaultValueB: P? = C()
}

Then the underlying value for defaultB is Sendable, meaning there's no potential for unsafe concurrent access here and the warning could be suppressed. However, this isn't about global actor isolated types and I don't think that discussion belongs in this proposal. I think that can just be a small revision to SE-0412.

4 Likes

Thank you. In your initial message you were talking about a warning, so that the developers would have to add an explicit nonisolated in front of their public static let. This sounded sensible. There's no inference in this case - everything is explicit.

My concern is that all bug reports and feedbacks that devs can submit to Apple frameworks will be left open in limbo, or closed as "works as expected" because the level of maturity required to consider "1. the type is isolated but this nested constant does not need to be and 2. this is just bothersome for users who are not me, 3. let's apply this trivial QoL enhancement right away" is high. A little kick (a warning) from the compiler would help framework devs to make the correct choice. There is an opportunity here.

Yes, I still think a warning is a good idea! I don't think such a warning needs an evolution proposal, but it can apply if:

  1. The static variable is a let
  2. The type of the static variable is Sendable
  3. The initial value of the static variable does not require isolation
  4. The static variable is inferred to have global actor isolation

The warning could be silenced by writing an explicit isolation on the variable, either nonisolated if that's what the programmer meant, or a global actor annotation if the programmer wants to leave the door open to later change the static variable to a computed property or change the initial value in a way that might require isolation.

Feel free to file a GitHub issue if you'd like to track that change.

2 Likes

OK, yes. I was under the assumption that actor isolation on a protocol implied that the conforming type would be isolated to the global actor, too. But in fact that's only the case 'if the conformance is stated at the primary declaration'. Good to know, thanks!

I've revisited proposal on global actors, under SE-316 statements protocol implementations within the same module inherit protocol isolation as whole, but outside of the module does not (wonder why distinction was made?). And clearly there is no way to know if that global variable with protocol type has implementation from the same module or not, so its assumed it does not inherit such isolation.

:star_struck: :gift: Global-actor isolated types: emit a warning for public sendable static properties that miss an explicit isolation annotation. · Issue #72456 · apple/swift · GitHub

2 Likes

This means that for such global-actor-isolated closures and functions, the @Sendable attribute is implicit.

I think the same logic applies to the closures isolated on the actor instance. So all isolated closures and functions can be implicitly @Sendable.

Note that under region isolation in SE-0414, capturing a non-Sendable value in an actor-isolated closure will transfer the region into the actor, so it is impossible to have concurrent access on non-Sendable captures even if the isolated closure is formed outside the actor.

Looks like it is not implemented yet. Using swift-DEVELOPMENT-SNAPSHOT-2024-03-13-a.xctoolchain and -enable-experimental-feature RegionBasedIsolation I’m still getting an error. Anyway, I think that’s a very important improvement. Looking forward. Thank you for fixing this!

Subclasses may add global actor isolation when inheriting from a nonisolated, non-Sendable superclass. In this case, an implicit conformance to Sendable will not be added, and explicitly specifying a Sendable conformance is an error:

That’s unnecessarily restrictive. I think we should treat superclass subobject as an non-Sendable object isolated to a region connected to the actor of the subclass. Upcasting should preserve that.

When erasing to existentials of protocols that don’t refine Sendable, is the resulting value considered to be non-Sendable and tracked by the region-based isolation?

But adding isolation in the subclass should be banned if class has required initializers:

class Base {
    required init() {}
}

@MainActor
class Derived: Base {
    required override init() {}
}

actor MyActor {
    var obj: Base
    init<C: Base>(_: C.Type) {
         // C.init() returns object isolated to self
        self.obj = C()
    }
}

_ = MyActor(Base.self) // ok
_ = MyActor(Derived.self) // unsound

Actor-instance-isolated closures can only be @Sendable if the closure type preserves the fact that it was isolated. The only way to represent this in the type system is using @isolated(any), and @isolated(any) function types are already specified to be @Sendable in the corresponding pitch.

That's right; this proposal lifts the restriction, and this proposal isn't implemented yet. This note is just to clarify that SE-0414 makes this rule safe.

I don't understand how allowing a Sendable conformance on the subclass is safe because superclass state is unprotected by the global actor, and there are still ways to access that unprotected state through the subclass. Are you imaging that inherited state/methods are somehow retroactively treated as isolated by the global actor? The proposal outlines some reasons why I don't believe that's possible.

EDIT: I've been staring at this for a while and I can't reconcile how a "non-Sendable subobject" of a sendable instance would work, because region isolation is cut off as soon as you reach a Sendable part of an object graph. Would you mind elaborating on how that would work? For example, how would this formulation prevent calling inherited nonisolated async methods from the superclass, which can access the unprotected state on self while that unprotected state presumably can be concurrently accessed from the global actor?

1 Like

Region isolation does not apply as long as we operate on a derived type, but should come back into play as soon as reference is upcasted. And upcasted reference should be in the region connected to the global actor of the subclass. And this should happen both when upcasting explicitly and when accessing members of the superclass.

Now, nonisolated async methods actually are isolated to the current task. So if the value is isolated to an actor, such methods cannot be called on that value, because it is not legal to merge actor-isolated region with task-isolated one.

// generic over isolation region
class Base {
    var k: Int = 0

    // Generic isolation
    func foo() { k += 1 }

    // Specific isolation - isolated to the current task
    // Can be called only if self is isolated to the current task too
    func bar() async {}

    // Generic isolation
    // Can be called only if self is isolated to the `owner`.
    func bar(owner: isolated (any Actor)?) {}
}

@MainActor
class Derived: Base/*<@MainActor>*/ {
    var mutable = 0

    // Isolated to @MainActor
    override func foo() {
        super.foo()
        mutable += 1 // ok
    }

    // Isolation does not exist.
    // self is isolated to invalid region.
    // This method is statically known to be unreachable.
    // As if it has an argument of type Never.
    override func bar() async {}

    // Technically this should be ok, but can be tricky to implement.
    // It is isolated both to @MainActor and to owner.
    // We statically know that owner is MainActor.shared.
    override func bar(owner: isolated (any Actor)?) {}
}

let d = Derived()
// []
let b: Base = d
// [{b, @MainActor}]
b.foo() // ok if current context is isolated to @MainActor
await b.bar() // error: cannot transfer MainActor-isolated value to the current task
await b.bar(owner: nil) // error: cannot transfer MainActor-isolated value to the current task
await b.bar(owner: MainActor.shared) // ok

If we use generic types, instead of isolations then example above is equivalent to the following:

class Base<Isolation> {
    func foo() {}
    func bar() where Isolation = Task {}
    func bar(owner: Isolation.Type) {}
}

class Derived: Base<MainActor> {}

let d = Derived()
d.bar() // error: instance method 'bar()' requires the types 'MainActor' and 'Task' be equivalent

There are ways to access superclass members without upcasting, for example:

protocol P {
  func requirement()
}

class Super: P {
  var mutable = 0
  func requirement() {
    mutable += 1
  }
}

@MainActor class Sub: Super /*,  Sendable */ {
}

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

@MainActor func test(s: Sub) async {
  Task.detached {
    await generic(s)
  }

  s.mutable += 2
}

We can model this by preventing the subclass type from being Sendable instead of inventing bespoke rules to prevent specific things that are unsafe like upcasting and inherited conformances. It's a much easier rule to understand and catching the unsafety falls out of existing rules.

generic() is async and nonisolated, meaning it is isolated to the current task. T is not known to be Sendable, to t is a value tracked by region-based isolation.

It is valid to capture s: Sub in the closure passed to Task.detached() - this preserves information that s is isolated to the @MainActor.

But passing s to generic() is not, because generic() expects argument isolated to the current task, while s is isolated to the @MainActor.

Trying similar example but without subclassing, I see that it fails, but IMO for a wrong reason:

protocol P {
  func requirement()
}

@MainActor
class K: P {
  var mutable = 0

  // error: main actor-isolated instance method 'requirement()' cannot be used to satisfy nonisolated protocol requirement
  func requirement() {
    mutable += 1
  }
}

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

@MainActor func test(s: K) async {
  Task.detached {
    await generic(s)
  }

  s.mutable += 2
}

I think conformance should be allowed, but passing s to generic() - not.

Looks like currently we interpret lack of isolation for P.requirement() as the fact that it can be accessed from any isolation. But then conformance Super: P also should be incorrect, because Super.requirement() is also not accessible from arbitrary isolation context, but only from the isolation context where instance of Super is isolated to, according to region analysis.

That intepretation of P.requirement() would be valid if P refines Sendable. Then value conforming to P does not depend on any regions, and lack of isolation means that it can be accessed from arbitrary isolation context.

But if P does not refine Sendable, then we should assume that it is implemented by a non-sendable type, tracked by region-based isolation. And tracking applies even when value is viewed through the protocol - as existential or as a value of generic type.

I think Holly is acknowledging that the rule you're identifying could probably be made to work, but she's also saying that it would be brittle, require a lot of extra logic, and largely defeat the purpose of having a superclass. I think she's right about all that; the idea that you can't freely convert a class reference to its superclass type is very unnatural in object-oriented programming. And Holly's suggested approach here doesn't preclude allowing it in the future if we feel it's important to take on. Do you have a concrete example of code you think would need to do this?

1 Like

I don't have any good examples where one would need to share GAI-subclass of non-sendable superclass with another isolation domain. I don't think it will be a high-demand feature.

I'm more concerned about reasons that prevent us from doing so - I suspect they can be symptoms of unsoundness in isolation checking. So allowing sending GAI-subclasses is a useful exercise to identify and fix them.

Allowing GAIT's to be Sendable in some cases, but not in other, IMO, is exactly "extra logic" that makes type checker less predictable and harder to understand. Imagine as a developer, you have a GAIT class, which has some logic which you want to reuse in other isolation domains. So you extract it into non-sendable base class. But now suddenly your class becomes non-Sendable. Few people would be affected by this, but those who will, gonna be very surprised.

Actually, I think being able to meaningfully override methods from the superclass will have much bigger impact. And this is an example of a side effect improvement that we get when trying to trace what prevents the sendability.

Or another example, what I wrote above about required initialisers. Do you agree that allowing GAI-subclasses with required initialisers would be unsound? Identifying this, is another side effect, that follows from the suggested rules.

So far, I was not able to understand your point about not being able to freely convert a class reference to its superclass type. If we don't retain isolation during upcasting, I'm not even sure what are the alternatives. Will the value live in the disconnected region? Or it won't be tracked by the region-based isolation at all? Or is upcasting banned? How do regions look like in the example below?

class Base {}
@MainActor class Derived: Base {}
let d = Derived()
let b: Base = d

That's now how Sendable checking nor region isolation work. If s is Sendable then it is not included in region analysis - it doesn't need to be because it's Sendable. The value s itself is not isolated to the main actor - what global actor isolation means on a type is all state and methods in that type are isolated to the global actor. This is the property that typically enables the implicit Sendable conformance - an instance of the type itself can be referenced by multiple isolation domains concurrently, but accessing its state needs to go through the actor's serialization mechanism, so the shared mutable state is protected by the global actor. Under region isolation, an instance of a main actor isolated class is not itself in the main actor region, but all of the non-Sendable state that you can get out of that instance are in the main actor region because they necessarily are returned from isolated methods.

Sendable checking also looks through to the underlying argument value, because even if the parameter type is not required to be Sendable, there's no potential for data races if the argument value passed is Sendable. This eliminates a very common source of false-positive data-race errors under the Swift 6 language mode.

The example I provided does not need generic to be async in order to demonstrate the problem:

protocol P {
  func requirement()
}

class Super: P {
  var mutable = 0
  func requirement() {
    mutable += 1
  }
}

@MainActor class Sub: Super /*,  Sendable */ {
}

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

@MainActor func test(s: Sub) async {
  Task.detached {
    generic(s)
  }

  s.mutable += 2
}

And changing the rules so that you cannot pass instances of global actor isolated types to generic functions that don't specify isolation in favor of enabling more subclassing patterns sounds like the wrong tradeoff to me.

The implications of "callable from an arbitrary isolation domain" are directly impacted by whether or not the value you're talking about is Sendable. Unspecified / nonisolated synchronous methods do indeed mean that the function is callable from any isolation domain, but if the method is on a non-Sendable type, the guarantee that you get is the method can never be called concurrently. Only one isolation domain ever has access to the value at a given point in the program, and that isolation domain is allowed to call the nonisolated method regardless of what that isolation domain is.

I have yet to see an example of unsoundness in isolation checking that are a result of the rules (not just compiler bugs), but if you have any examples of unsoundness, please do surface them. The impression that I've gotten from this discussion is that you're trying to enable more code to become valid under strict concurrency checking, not that you're concerned about holes in the model.

Thank you for the detailed answer. It did help me to understand how typechecking works currently.

Doesn't this apply to the superclass subobject?.

I'm not familiar with the issue. Could you give an example?

You are still able to pass GAITs to generic functions under the rules that I've suggested:

@MainActor func test(s: Sub) async {
  Task.detached {
    // ok
    // equivalent to:
    //   let s1: some P = s // [{s1, @MainActor}]
    //   generic(s1)
    // s1 cannot be passed to the synchronous generic(),
    // because it requires it's argument to be isolated to the current context.
    generic(s)

    // await-ing to hop to the MainActor should be able to fix this
    // but none of the 
    await generic(s)
  }

  // ok
  // equivalent to:
  //   let s2: some P = s // [{s2, @MainActor}]
  //   generic(s2)
  // s2 can be passed to the synchronous generic() in the @MainActor context
  generic(s) 
  s.mutable += 2
}

I'm a bit confused about what consitutes a tradeoff here. Do you see a decrease in expressivity or you mean only increased implementation costs?

Well, both. My professional intuition sounds an alarm, and I'm trying to work out what exactly it is concerned about. I guess it could be both false positives (lack of expressiveness) and false negatives (unsoundness). But so far, indeed, discussion have surfaced no cases of unsoundness, only limitations in expressiveness.

Let's put my suggestion aside for a while, and focus on the proposal.

  1. Under the current proposal, how do regions look like for the following snippet?
class Base {}
@MainActor class Derived: Base {}
let d = Derived()
let b: Base = d
  1. Under the current proposal, can GAI-subclasses add their isolation when overriding methods from the superclass?
class Base {
    var counter: Int = 0
    func increment() {
        counter += 1
    }
}

@MainActor
class Derived: Base {
    var isCounting: Bool = false
    func increment() {
        isCounting = true
        super.increment()
        isCounting = false
    }
}
  1. Under the current proposal, can GAI-subclasses exist in the present of the required initialisers? Does overridden initialiser need to be nonisolated or can it be isolated?
class Base {
    required init() {}
    func foo() {}
}

@MainActor
class Derived: Base {
     override required init() {
        super.init()
     }
}

actor Owner {
    let object: Base

    init(factory: Base.Type) {
        self.object = factory.init()
    }
}

To be able to safely override nonisolated method is non-Sendable superclass with isolated method in derived class, compiler would need to be able to prove absence of conformance to Sendable. For @unchecked conformance, this is not possible, because @unchecked conformance can be retroactive.

@unchecked Sendable conformance is be used to circumvent isolation checking even without subclassing - this might an argument to disregard @unchecked conformance. But even with @unchecked conformance out, it is still quite tricky to prove even for conditional checked conformance. If subclass does not fully specify all generic params of the base class, then this is not provable in general case.

I understand now that lack of Sendable conformance means that current code is not allowed to share the value with other isolation domain, but does not mean that some other code won't share it.

But this might be still applicable to the protocol conformance. For protocols we know if they refine Sendable or not, and this cannot be changed retroactively. So potentially we can allow GAIT's to satisfy nonisolated requirements with isolated implementations. But, of course, that's out of scope of the pitch being discussed.