SE-0470: Global-actor isolated conformances

Hi everyone,

The review of SE-0470: Global-actor isolated conformances begins now and runs through April 3, 2025.

This proposal is another one of those contemplated in the recently approved vision document to improve the approachability of Swift's data-race safety facilities.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager via the forum messaging feature. When contacting the review manager directly, please keep the proposal link at the top of the message.

Trying it out

If you'd like to try this proposal out, you can download a toolchain supporting it. You will need to use a recent main development snapshot and to enable the experimental feature flags outlined in the proposal.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at swift-evolution/process.md at main · swiftlang/swift-evolution · GitHub.

Thank you,

Xiaodi Wu
Review Manager

12 Likes

Really happy to see something along these lines. I'm confused about the role of SendableMetatype in the current proposal, though. In previous drafts, I know that this was imagined as a real sendability constraint on the type value which prevented the type from being e.g. captured in a sendable closure. In the current proposal, however, it seems to function solely as a way of preventing isolated conformances from being passed (thus granting the function the ability to use the conformance in a sendable closure, even if the conforming type isn't sendable). This seems like a strange way to write this — it looks like some kind of type restriction, which it doesn't actually seem to be anymore. Wouldn't the following be both a clearer and a more general way to express this?

  1. A conformance requirement can be explicitly declared nonisolated, e.g. where T: nonisolated P.
  2. Conformance requirements are implicitly nonisolated when the conforming type is constrained to be Sendable.
  3. Clients may not use a global-actor-isolated conformance to satisfy a nonisolated conformance requirement.
5 Likes

I think this will bring a huge usability improvement for protocols, however I think the biggest win is naturally extending this to include @Sendable protocol conformances as well:

protocol DataProviding: Sendable {
   func identifier() -> String
}

class MutableFoo, @Sendable DataProviding {
   private var mutData: Any
   private let someInternalStr: String

   // sendable impl
   func identifier() -> String {
      return someInternalStr
   } 
}

I also have to agree with the above comment regarding SendableMetatype. It's role in the proposal was quite confusing.

The proposal makes sense to me, however this paragraph worries me:

Initial testing of an implementation of this proposal found very little code that relied on Sendable metatypes where the corresponding type was not also Sendable. Therefore, this proposal suggests to accept this as a source-breaking change with strict concurrency (as a warning in Swift 5, error in Swift 6) rather than staging the change through an upcoming feature or alternative language mode.

Swift Testing relies on macros that expand and reference somewhat arbitrary types from within nonisolated helper functions. Did you do any testing to see if the @Test or @Suite macros would break?

As well (apologies if this is in the proposal and I missed it), is there a mechanism to opt a protocol out of this functionality, e.g. if it models some concept that cannot function correctly when arbitrarily isolated (but which also does not imply Sendable)? I don't have a specific idea of such a thing offhand, but my lack of imagination isn't worth much.

Moving this discussion from pitch thread to here:

Our app still targets iOS 15, so it cannot use DispatchSerialQueue. Instead there are custom executor implementations that attempt to wrap DispatchQueue (non-main). Since underlying Dispatch API implementing isIsolatingCurrentContext() is private, custom implementations won't be able to implement it. They can only implement checkIsolated(). But even if implemented, checkIsolated() won't be called in pre-iOS 18 runtimes.

Assuming X is the runtime version where this feature becomes available:

Min Version Runtime Version Behavior
< iOS 17 < iOS 18 Custom executor; assumeIsolated() fails; dynamic cast ignoring current executor could be used as a workaround.
< iOS 17 >= iOS 18, < X Custom executor; assumeIsolated() passes; dynamic cast ignores custom executor.
< iOS 17 >= X Custom executor; assumeIsolated() passes; dynamic cast checks custom executor without isIsolatingCurrentContext(). :warning:
>= iOS 17 < iOS 18. DispatchSerialQueue; assumeIsolated() fails; dynamic cast ignoring current executor could be used as a workaround.
>= iOS 17 >= iOS 18, < X DispatchSerialQueue; assumeIsolated() passes; dynamic cast ignores custom executor.
>= iOS 17 >= X DispatchSerialQueue; assumeIsolated() passes; dynamic cast checks custom executor, with isIsolatingCurrentContext() provided by DispatchSerialQueue.

There is still one problematic case. I don't think we should let conditional casts silently pass, but not being able to use force casts can become a blocker.

I don't like idea of as! and as? having different behavior. We can keep their behavior in sync, if there is another way of asserting the current actor, that can be combined with dynamic casts.

And there is already one - it is assumeIsolated(). But currently it does not dynamically store the isolation that has been recovered. If it starts doing so, and dynamic casts can be used inside the closure passed to the assumeIsolated() - I think it will cover the remaining problematic use case.

Can this be done as part of SE-0470?

1 Like

I've found a problem with down casting, but it is not specific to global-actor isolated conformances, but rather a general problem with marker protocols:

Normally it is not possible to cast to Sendable:

print(x as? Sendable) // error: marker protocol 'Sendable' cannot be used in a conditional cast

But if you ask nicely, it becomes possible:

func castPlease<T>(_ x: Any, to: T.Type) -> T? {
  return x as? T
}

protocol P {
  func foo() 
}

@MainActor
class MA: @MainActor P {
    func foo() {}
}

class NS {}

@MainActor
func test() {
  print(ObjectIdentifier((any P).self))
  print(ObjectIdentifier((any P & Sendable).self))
  print(castPlease(MA(), to: (any P & Sendable).self))
  print(ObjectIdentifier(Any.self))
  print(ObjectIdentifier((any Sendable).self))
  print(castPlease(NS(), to: (any Sendable).self))
}

// Prints:
// ObjectIdentifier(0x000000010456d340)
// ObjectIdentifier(0x000000010456d340)
// Optional(foo.MA)
// ObjectIdentifier(0x0000000104529de0)
// ObjectIdentifier(0x0000000104529de0)
// Optional(foo.NS)

Since metatypes with and without Sendable look the same in the runtime, this cannot be solved within the scope of the global-actor isolated conformances. I guess this could be solved by introducing another marker protocol (e.g. Castable), but that is out of scope of this feature.

I think the SendableMetatype name isn't a good one for the role this (marker) protocol plays any more. Something like NonisolatedConformances would be more accurate to what it does.

It's more general, yes, and something similar is described in future work. Note that your #2, which infers nonisolated on conformance requirements T: P when T is Sendable, is effectively a type restriction from the user perspective... but it's implemented as an inference rule that desugars down to the conformance restrictions.

My main concern is around the complexity of introducing a new kind of requirement, T: nonisolated P, into the language/formal model/implementation. I don't really know yet how it's going to work out; new kinds of requirements can have ripple effects.

Stepping back a little bit, this proposal involves a source-breaking change that will start to reject some generic code that carries conformances across an isolation boundary. We think that:

a. Most generic code never crosses any isolation boundaries, so it is unaffected, and
b. Most generic code that does carry a conformance T: P across an isolation boundary also carries a value of the conforming type across an isolation boundary, so it will also have a constraint T: Sendable.

Under these assumptions, the source-breaking change has very little practical impact. The code that does break looks a bit like this:

protocol P {
  static func f()
}

func callF<T: P>(_: T.Type) {
  Task.detached {
    T.f() // needs to error, because it carries T: P across an isolation boundary
  }
}

We need a type-checking model that produces that error, and a way to describe to that model that T:P must not be allowed to use an isolated conformance so we can fix callF. According to (b), that could be T: Sendable, but that's a stronger constraint than we really want for this code. So we're seeking the constraint in between.

Something like T: SendableMetatype or T: NonisolatedConformances expresses it as a type-based property, the same way T: Sendable does. The inference rule for Sendable is then encoded in protocol inheritance:

/*@marker*/ protocol SendableMetatype { }
/*@marker*/ protocol Sendable: SendableMetatype { }

Your suggested nonisolated conformances express it as a conformance property. This is more general when there are multiple conformances involved, e.g.,

protocol P {
  static func f()
}

protocol Q {
  static func G()
}

func callFG<T>(_: T.Type) where T: P, T: nonisolated Q {
  Task.detached {
    T.f() // needs to error, because it carries T: P across an isolation boundary
    T.g() // okay, because `T` has a nonisolated conformance to Q
  }
}

In the proposal's model (T: NonisolatedConformances or T: SendableMetatype), there's no way to express that T: P can be isolated but T: nonisolated Q cannot. I suspect we don't need this flexibility, and would rather not introduce the complexity of this model into the system if we don't need it.

In writing this, I'm convincing myself that I prefer the non-sendable-metatype model from earlier pitches, rather than the partial implementation of your nonisolated conformance requirements that is in the proposal as under review. I don't think we need the complexity of the model you propose, and the non-sendable-metatype model fits neatly into the Sendable design we have today.

As noted in future directions, I think your "sendable protocol conformances" are equivalent to "nonisolated conformances", but that the isolation terminology is better here.

Yes, some of the code tested used Swift Testing.

You could make the protocol inherit from SendableMetatype, which would prevent an isolated conformance to it.

Doug

1 Like

Generally speaking, we try to keep back-deployment considerations out of the design of language features, because they are vendor-specific and if taken to the extreme, can negatively affect the long-term language design.

The dynamic casting machinery is part of the runtime, and "force cast" is effectively a conditional cast + a force unwrap. We can encode the requirements of an isolated conformance in a manner that they will cause an older runtime to always reject the conformance (so it never works dynamically) or always accept the conformance (so it will always work dynamically). The implementation currently does the latter, which admits data races. The only alternative I can think of that wasn't taken would be to inject an otherwise-unused assumeIsolated {} into each of the functions that satisfies a requirement in an isolated conformance, but only do so when back-deploying. That way the cast (whether conditional or forced) would succeed (ugh), but you'd get a fatal error at runtime rather than a data race.

Sneaky! The prototype implementation does not implement it yet, but the last paragraph on data-race safety in the proposal indicates that we will need to communicate the Sendable-ness of an existential type down to the runtime so it can refuse to use an isolated conformance for a cast to (e.g.)any P & Sendable. I believe that covers this case.

Doug

Min Version Runtime Version Behavior
< iOS 17 >= X Custom executor; assumeIsolated() passes; dynamic cast checks custom executor without isIsolatingCurrentContext(). :warning:

The case that I've described uses modern SDK and latest runtime version, so it is a not back-deployment. It's more about backward-compatibility and adoption strategy.

On the second thought, the root cause of problem seems to be the fact that availability of the SerialDispatchQueue is not controlled by the runtime version, but by the minimum deployment version at compile time. If if #available machinery could be used with SerialDispatchQueue this solves the problem and reduces 6 cases that I've described into 3.

actor MyActor {
    private let executor: any SerialExecutor

    @available(iOS 17, *)
    init(queue: DispatchSerialQueue) {
        // Has isIsolatingCurrentContext() when it is needed
        self.executor = queue // error: Cannot assign value of type 'DispatchSerialQueue' to type 'any SerialExecutor'
    }

    init(queue: DispatchQueue) {
        // No isIsolatingCurrentContext(), but it is not needed
        self.executor = CustomQueueExecutor(queue: queue)
    }
}

Again, back-deployment is not the case that I was talking about, but I like the idea. Some runtime diagnostics would be great. And if it could be configurable that would be fantastic! E.g. we would probably prefer to silently send event to Crashlytics, rather than crash in production.

That would require being able to distinguish between (any P).self and (any P & Sendable).self in the runtime. Does that mean that Sendable stops being a marker protocol? Does that mean that restriction for casting to Sendable can be lifted everywhere (e.g. including the non-sneaky example)?

Oh, this is tricky. I was thinking of it as something we would do specifically for the casts themselves, because changing the identity of existential types (such as any Sendable & P is distinct from any P at runtime) can have far-ranging implications. But that means that my approach here won't fix this existing issue with marker protocols.

Doug

Ah, I see that in the Dispatch headers. This is outside of the Swift project's scope; please do file a Feedback .

Doug

1 Like

For folks looking to do this, grab a recent toolchain (e.g., one published on March 25th). There are three (!) separate experimental flags that can be enabled:

  • IsolatedConformances to enable this feature

  • InferIsolatedConformances for the upcoming feature that infers global-actor isolation for conformances of a global-actor-isolated type to a non-isolated protocol

  • StrictSendableMetatypes to treat a metatype like T.Type as not being Sendable when T has a SendableMetatype constraint on it (related to the discussion here). Enabling this with strict concurrency gives one an over-approximation of the source compatibility issues introduced in the proposal.

    Doug

1 Like

I've written up a change to the proposal under review that does the above. Specifically, rather than try to treat conformances as isolated within generic functions, we instead say that a metatype T.Type is only considered Sendable when T: MetatypeSendable. This is a simpler model than the one in the proposal as stated, but we don't lose much flexibility.

Doug

1 Like

Does this proposal also allow for a global-actor isolation conformance on a non-global actor isolated type or actor?

@MainActor protocol MainProtocol {
   // ...
}

actor a: @MainActor MainProtocol {

}

Yes, any type can have a global-actor-isolated conformance. Note that, in your example, you don’t need the protocol to be isolated to the main actor, and in general it won’t be.

Doug

Thank you all. The language steering group has decided to accept this proposal.

2 Likes