[Pre-pitch] Isolated conformances

Hi all,

The prospective vision for making concurrency more approachable contains a section on isolated conformances. The idea here is that, when you have a @MainActor-isolated struct or class, it's onerous to have to satisfy all of the requirements of a protocol with nonisolated functions. Instead, we could make the conformance itself be isolated, and ensure that it doesn't get used from outside the actor. This idea isn't new: it was discussed in the future directions of SE-0313. The basic idea looks like this:

protocol P {
  func f()
}

@MainActor class C: isolated P { 
   func f() { } // @MainActor-isolated, which is okay because the conformance to P is @MainActor-isolated
}

nonisolated func callPF<T: P>(_ value: T) {
  t.f()
}

@MainActor func callPFC(c: C) {
  callPF(c) // okay, uses isolated conformance C: P entirely within the @MainActor isolation domain
}

nonisolated func callPFCIncorrectly(c: C) {
  callPF(c) // error: uses isolated conformance C: P outside the @MainActor isolation domain
}

This is a tricky feature. I've created a pitch document describing an approach to isolated conformances that I believe to be workable. However, I'm calling this a "pre-pitch" because I'm neither convinced that this design is fully data-race-safe nor am I convinced that the benefits of the feature will outweigh its downsides---which include some source compatibility effects described in the document.

I'd love feedback on this design direction. Does it make sense? Are there data-race safety holes that it doesn't close? Will the source compatibility break affect code you know about?

Doug

17 Likes

"Isolated conformances" gave me the impression that this feature is about making extensions non-global, so if multiple of the same name are defined, it isn't ambiguous which is chosen at runtime.

If I'm not the only one to think that, could we rename this to "Actor-isolated conformances"?

5 Likes

Isn't it more "type isolated conformances", as the isolation is provided by the conforming type?

@Douglas_Gregor Could this be the default for isolated types conforming to non-isolated protocols? Is it ever correct for an isolated type to conform to a non-isolated protocol? Or even possible to do so correctly?

1 Like

I would have called that "access-level modifiers on conformances", or similar, like
we recently did for import declarations.

I think there's nothing else they could be isolated to.

It could be invisible in the source, in the sense that we could decide that a conformance is isolated because it uses an isolated declaration to satisfy a nonisolated requirement. But I don't think it should be the default, because there exist non-isolated conformances today that work fine. (I probably wouldn't want it to be invisible either, but it's an option)

Oh, absolutely. If your conformance is based on immutable state, it's fine.

@MainActor class Person: Equatable {
  nonisolated let recordID: UUID

  static func ==(lhs: Person, rhs: Person) -> Bool {
    lhs.id == rhs.id
  }
}

Doug

6 Likes

Funny, I had the exact same chain of thought, until I remembered that "isolation" has a specific meaning in Swift.

By the way, with regards to that other interpretation: I remember some discussions about why Haskell does't support private instances, the consensus being that it would be a bad idea due to potential coherence issues in instance resolution.

If I understand the pre-pitch right, C effectively does not conform to P, but essentially to a new protocol @MainActor P (pseudo-syntax I just made up). This feels like a form of generics where the protocol could be treated as having an isolation domain parameter (?). The relation between the protocols being P: (@MainActor P).

What makes this a bit hard to wrap my mind around are IMO all the implicit rules around how functions can be called while staying in an isolation domain. callPF is essentially generic over which isolation of P it can take, so in pseudo-syntax:

@A func callPF<A: Actor, T: @A P>(_ value: T) { ... }

I haven't worked enough with Swift concurrency to be familiar enough will all of these rules, so feel free to point out any misunderstandings.

1 Like

What if callPF() is an async function?

nonisolated func callPF<T: P>(_ value: T) async {
    t.f()
}

callPF() itself will run in global executor, but it won't be certain where t.f() runs. If t is an instance of C, t.f() runs in main actor; otherwise it runs in global executor. I wonder if this uncertainty will be an issue when f() takes parameters or returns value?

2 Likes

Initial thoughts: this could be a massive unlock, if the constraints deemed necessary for this feature don’t hamper its usability too much in practice.

I’m intrigued by the point on source compatibility

Note that, any time a value of type T crosses an isolation boundary, it's metatype is accessible via type(of:), so it also crosses the isolation boundary. This provides us with an inference rule that can help lessen the impact of this source compatibility break: if a generic signature contains a requirement T: Sendable , then we can infer the requirement T.Type: Sendable .

Would this be the first instance of bidirectional type inference in functions (as opposed to just closures)? And would it be possible to go a step further — making the source break slimmer — by inferring T.Type: ~Sendable iff it would be valid given the function body? One could still spell out the Sendable or ~Sendable constraint in order to make this explicit. (I think this suggestion wouldn’t entirely fix the source breakage because inferring T.Type: ~Sendable for func foo<T: P>(…) could affect overload resolution for any bar(T.self) calls inside foo but perhaps/hopefully I’m missing a caveat.)

2 Likes

For me the main question is how often it actually makes sense to have such conformance in the code, and if the provided solution for isolated conformances – which seems to be highly restricted on usage points – would be able to resolve issues that arise currently?

As for me, all the restrictions described in (pre)pitch makes total sense and maybe some cases end up being even more limiting on use points (so far I couldn't thought of any cases that won't be covered in the pitch document). At the same time, all these rules are quite complex to follow and keep in mind, so they might only shift struggles from one issue to another.

While I do think that there are valid cases for that, even though they are rare, the majority of issues that brought up by inability to use protocol in conformances often comes from types with mixed isolations, and the latter more likely will become even harder to use with these new rules.

1 Like

Concurrency evolutions usually need to talk about KeyPaths. I think this is within the rules in the pitch:

protocol P {
    var v: Int { get }
}

@MainActor
final class C: isolated P {
    let v = 3
    init() {}
}

@MainActor
func f() {
    let c = C()
    Task.detached {
        // sending c; none of its methods are usable, but that's OK…
        c[keyPath: \P.v] // uh-oh, accessed `@MainActor` state on another task
    }
}
2 Likes

Hmm if I understood the pitch correctly, the idea behind Rule #2:

  1. When an isolated conformance is used to satisfy a generic constraint T: P, the generic signature must not include either of the following constraints: T: Sendable or T.Type: Sendable.

Is to leverage the existing sendability checks to ensure that an instance of a type S with an isolated conformance never leaves the isolation domain in which it was created (which, by Rule #1, must be the same isolation domain as the isolation domain of the isolated conformance).

I think it totally makes sense from an implementation perspective, though so far I’m finding it challenges with my previous mental model of what Sendable means. I was under the impression that a global actor isolated type are implicitly Sendable, but here (again, IIUC) trying to pass S to a function:

func foo<T: Sendable & P>(_ value: T)

Would require removing the Sendable constraint from T, even though S is sendable (implicitly, or so I thought), so the body of foo treats value as non-sendable and as such doesn’t ever put value in a place where the information that the protocol conformance was isolated is lost.

If so, I find it really hard to wrap my head around the need to remove a protocol constraint (Sendable) from foo to be able to pass a type that does conform to that removed protocol constraint. Perhaps I’m missing something here or I already had a fundamental misunderstanding about GAITs and how implicit sendability works. Or maybe the way to look at it is that while T is implicitly sendable, the isolated conformance isn’t.

I appreciate how this pitch would automatically remove huge swaths of complexity in real world projects, I’m hugely in favor of the proposal conceptually. But I wonder if the currently proposed design would (much like RBI) result in cryptic error messages that most developers can’t understand at all. Maybe this is a compromise that must be made.

When type is erased inside the foo, there is nothing to carry isolation within P method, so when foo accepts Sendable parameter, it can escape to another isolation without any mechanism to restore this:

@MainActor struct S: P {}

let s = S()
foo(s)

func foo<T: Sendable & P>(_ value: T) {
    Task {
        value.bar()  // should be called in actor isolation, but won't
    }
}

To support this it would require something like the following, I guess

func foo<T: Sendable & isolated P>(_ value: T) async

so that each invocation to the P would require await and may suspend.

Yeah I understand why it’s needed, just pointing out that I found it conceptually hard to grasp. If I understood correctly, when using this feature the compiler would emit an error where foo is called:

@MainActor struct S: P {}

let s = S()
foo(s) // ❌ error: foo()’s type parameter requires Sendable

And regardless of what the error message ends up being, I don’t see how it wouldn’t be confusing for developers that the solution here is to remove the Sendable constraint from foo’s signature, given that:

  • S is sendable.
  • foo works with sendable types.

So it’s hard to understand what the compiler is complaining about, much less why the solution is to remove a constraint from foo that is already fulfilled.

I guess the right mental model for this (please correct me if I’m wrong) is that S here behaves as-if the type was not Sendable when used as the argument for a parameter expecting a type conforming to P without any isolation restrictions, regardless of whether S as a type is Sendable or not.

Maybe a good error message is all it’s needed, but I’m having a hard time picturing what it could be at the moment.

1 Like

Well, foo doesn't operate on just any "sendable types"—it is constrained to sendable Ps. I suppose the mental model is that, if S is an isolated P, then of course it cannot be a sendable P...

I think this is a good argument why the isolated label should not be inferred or elided:

@MainActor struct S: isolated P { }
// âś…

@MainActor struct S: P {}
// ❌ As elsewhere in the language, `Sendable` is inferred, not `isolated`
3 Likes

Arguably, precisely because it’s isolated to a global actor, it should be sendable. Isn’t that how it works for types? Except with isolated protocol conformances, the compiler wouldn’t have enough information to enforce that any calls that use the protocol conformance remain isolated to the global actor (it wouldn’t even be possible for existing generic code and synchronous protocol functions). But, conceptually, I find it a bit confusing to say that isolating a type to a global actor makes that type sendable, but isolating a protocol conformance to a global actor makes that conformance… not sendable.

And the lines are even more blurred for async functions in protocols:

protocol P {
  func foo() async
}

@MainActor struct S: isolated P {
  func foo() async { ... }
}

Here S is sendable, and it appears to me as “a sendable P” (because await foo() can be safely called from any isolation domain, it’ll just need to perform an executor hop). Yet (I think) it still isn’t possible to pass an instance of S to a parameter of type P without losing the Main Actor isolation, so it still needs to be forbidden.

I’m not convinced that this limitation can be accurately described as the conformance “not being sendable”, despite it being built around sendability checks.

At the very least, it seems like this would add a new dimension to sendability that didn’t exist until isolated protocol conformances. Until now, when looking at this type:

@MainActor struct S: ... { }

And asking the question ”is this type sendable?” the answer used to be ”yes” with no caveats: global actor isolated types are implicitly Sendable. But with this proposal, S: isolated P is sendable, but not a valid value for a Sendable & P parameter. Which is fine, I guess, just pointing out I find it conceptually hard to grasp.

I’m both fascinated and terrified by the idea of using Sendable conformance as a syntax for disambiguating between isolated and nonisolated conformance requirement.

Fascinated, because IMO it does a great job at capturing intention of the existing use cases, without introducing new syntax.

And terrified, because that’s just not how protocols work. Good diagnostics would enable people to successfully use this feature in practice, but probably still would leave a feeling of confusion. If Sendable was an existing keyword, I think this pitch would land smoother.

Isolated conformances cannot be declarared for protocols that refine Sendable - worth to include this in the proposal text.

Proposal mentioned dynamic casts, but this needs more information.

I can think of two approaches:

  1. Isolation information is stored in the metadata, and runtime functions performing dynamic cast check for the current isolation. Current isolation can be dynamic. I cannot think of any scenarios where this can be unsound, but this requires changes in stdlib. Which means version limitations or maintaining back-deployment library.

  2. Isolation information is compile time-only. It is forbidden to perform a dynamic cast from Sendable to non-Sendable type (because it introduces new region without a known actor to connect them to). Current isolation must be statically known. No changes in stdlib, but diamond protocol hierarchies can lead to unsoundness.

protocol A {}

@MainActor
protocol B: isolated A {}
protocol C: A {}

// Is conformance to A isolated or not?
@MainActor
class X: B, C {}

// Assuming it is non-isolated 
let d: any C & Sendable = X()
let c: any C = d
let a: any A = c
let b: any B = a as! B // unsound

@MainActor
class Y: isolated A {}


func f(_ object: Any, actor: isolated (any Actor)) {
    let a = object as? A
}

// Should the cast succeed?
f(Y(), actor: MainActor.shared)

In either way, if this can be solved for protocols, I think the same solution should work for class inheritance too. Allowing to lift restrictions imposed by SE-0434 and make global actor isolated subclasses Sendable.

2 Likes

I'm explicitly trying not to have to make it a different protocol, dynamically carry isolation information through the generics system, or put any restrictions on the protocol itself. The conformance itself is definitely more restricted, though.

t.f() is a synchronous call; it runs wherever the enclosing async function runs. Now, the call into callPF, if it's coming from some actor's isolation domain, will require that T be Sendable. Good catch: that's not covered by my writeup, because the Sendable constraint comes not from the generic signature <T: P> but from the fact that we're crossing an isolation boundary going into this function.

It shouldn't be. @Slava_Pestov actually came up with a great way to model this in the language. Essentially, we introduce a new marker protocol SendableMetatype and have Sendable inherit from it. Then, we say that T.Type: Sendable when T: SendableMetatype and voila! We have the inference rule above with very little magic. I went ahead and implemented this idea behind an experimental feature flag.

Swift's philosophy is not to infer constraints on the interface from the implementation of functions. If we end up deciding to make this change behind an upcoming feature flag, then it's plausible that we could provide tooling that did the inference to help code migrate to the upcoming feature flag.

In my haste to get some technical details written down, I probably undersold the benefits of this feature. Right now, if you have a MainActor-isolated type, you cannot reasonably conform to any protocol without making every witness nonisolated + implemented with assumeIsolated. That means these types cannot be Hashable, or Encodable, or Collection, or even CustomStringConvertible. This basically cuts them off from much of the standard library, and many other generic libraries that have nothing to do with concurrency whatsoever---so it neither runs into the source-compatibility issues described in the pre-pitch, nor introduces any Sendable constraints that would prevent the use of isolated conformances.

Can you provide an example of what you mean here about the mixed isolations?

Good example! Note that the \P.v will need to make use of the conformance of C: P, which is @MainActor-isolated and therefore not permitted outside of a @MainActor context (that's rule #1).

Right.

Global-actor-isolated types are implicitly Sendable, because their state is protected by the global actor. When we still have the identity of the type S, we know what the isolation of the various operations we're using is.

When we go into a generic function, we lose the identity of S, and all we have to go on are constraints on what the type T can do. The implementation of foo tells us what constraints we need, so foo should only have a T: Sendable constraint if it, in fact, needs to send values of type T to another isolation domain. It doesn't actually matter whether concrete type like S that it's called with are Sendable.

Definitely a valid concern! I haven't even tried to write the error messages for this yet, but the explanation might be something like: conformance of S to protocol P is isolated to the main actor, so it cannot be passed to a function that could send it outside the main actor.

I'm not sure I follow your logic here. The isolated conformance itself is spelled isolated. At a point of use, isolated conformances are incompatible with Sendable constraints. It's like if a generic signature required both T == Int and T == String, you'd have an incompatibility in the signature.

Yes, that's a good point!

This is the approach I'd planned to implement. It does mean that older runtimes would not have the exact behavior we want---most likely, they'd end up allowing the cast, and therefore let the isolated conformance leak outside of its domain.

Doug

3 Likes

I was talking about requirement, not conformance - this part from the vision:

A generic function that can work with both isolated and nonisolated conformances should be able to declare that it can accept an isolated conformance. It would then be restricted to only use the conformance from the current concurrency domain, as if it were a sort of "non-sendable conformance". This is an important tool for generic libraries such as the standard library, many of which will never use conformances concurrently and so are fine with accepting isolated conformances.

Somewhere in the middle of the [Prospective Vision] Improving the approachability of data-race safety this syntax was mentioned:

// Isolated conformance is sufficient, nonisolated is also ok
func f<T: isolated P>(_ x: T) { ... }
// Requires nonisolated conformance
func f<T: nonisolated P>(_ x: T) { ... }

with preference for <T: P> to default to <T: isolated P>.

You pitch uses T: P as a syntax for T: isolated P, achieving the preferred default. And T: P & Sendable for T: nonisolated P. Which also makes sense, because nonisolated conformance is beneficial only when type is also Sendable.

Technically speaking, that's not true. Sendable type with non-sendable conformance is pretty exotic, but still meaningful - that means that value itself can be sent to other domains, if conformance is dropped:

@MainActor
func f<T: Sendable & isolated P & nonisolated Identifiable>(_ x: [T]) async {
    // Can use requirements of P
    for item in x {
        item.foo()
    }
    // Cannot use requirements of P
    let chosen = await identify(x)
    // Can use requires of P again
    chosen.foo()
}

nonsiolated func identify<T: Sendable & nonisolated Identifiable>(_ x: [T]) -> T { ... }
2 Likes

I understand that. What I'm trying to point out is that it may be confusing for a developer to discover that removing a constraint (Sendable) from foo<T: Sendable & P>(_ value: T) has the effect at the call site of removing the error when using an instance of S (a type the developer knows is Sendable, so it "shouldn't"[1] be affected by the constraint being removed).

Usually, if removing a constraint from a parameter gets rid of an error at the call site (foo(s)), that means s didn't meet the constraints. That technically is still the case here: S does not meet the constraints, because although S is sendable, passing a isolated P to a parameter expecting just "P" creates a hole in the sendability checks unless S is treated as non-sendable. But that is not a constraint that can be understood by looking at the protocol conformances of S or the requirements of foo[2].

I was just bringing this up because while I agree the "happy path" of isolated conformances greatly improves the ease of use of concurrency checking, I think one big thing to avoid if possible are the kind of errors that end up causing people to play a sort of whack-a-mole game of sprinkling the codebase with Sendable annotations in the hopes it would make hard to understand concurrency errors go away. And this seemed like an instance of an error where adding/removing Sendable has hard to understand repercussions.

Sure, most of the time removing Sendable from foo<T: Sendable & P>(_ value: T) wouldn't magically make the code start compiling when foo(s) is used, because the most likely scenario is that foo does indeed need to send the value to another isolation domain if the constraint is there.

So in addition to removing the error at the call site (foo(s)) it would emit a different error (this time, inside the body of foo) wherever that parameter is sent to a different isolation domain.

In fairness, maybe I'm overthinking this. Maybe the error when foo(s) is written will be enough for developers to understand the issue. It's hard to know without actually using it.


On a slightly different topic: is there a reason why this same approach couldn't be extended to having global actor isolated conformances for types that are not global actor isolated types themselves?

With the pitched approach, adding a single isolated conformance would require making the entire type a GAIT, and then adding lots of nonisolated annotations for all the non-isolated conformances. Starting with a class like this:

final class FooObject: Identifiable, Equatable {
    var id = UUID()
    static func == (lhs: FooObject, rhs: FooObject) -> Bool {
        lhs.id == rhs.id
    }
}

You'd need to rewrite it to this to add the isolated P conformance:

@MainActor final class FooObject: Identifiable, Equatable, isolated P {
    nonisolated var id = UUID()
    nonisolated static func == (lhs: FooObject, rhs: FooObject) -> Bool {
        lhs.id == rhs.id
    }
    f() {}
}

While it seems like it should be possible to have a non-GAIT with a single isolated conformance, using the same rules described in the pitch:

class FooObject: Identifiable, Equatable, @MainActor isolated P {
    var id = UUID()
    static func == (lhs: FooObject, rhs: FooObject) -> Bool {
        lhs.id == rhs.id
    }
    @MainActor f() {}
}

And avoid the need for all the nonisolated boilerplate if most of the conformances are not isolated. Perhaps a more advanced use case, but there are a few scenarios in which one genuinely needs a nonisolated type with a few @MainActor-isolated properties.


  1. It should, that's the faulty assumption. But understanding that requires somewhat deep knowledge of how isolated conformances work. ↩︎

  2. I guess it could be understood by noticing isolated P vs P, but then defaulting GAIT conformances to be isolated unless otherwise specified would get rid of this clue. ↩︎

1 Like