Isolated type vs. Sendable isolated type

Why does this code compile:

@MainActor
protocol P: Sendable {}

@MainActor
class C {
    let p: any P
    
    nonisolated init(p: any P) {
        self.p = p
    }
}

While this does not?

@MainActor
protocol P {}

@MainActor
class C {
    let p: any P
    
    nonisolated init(p: any P) {
        self.p = p // Main actor-isolated property 'p' can not be mutated from a nonisolated context
    }
}


Isn't an actor-isolated type already Sendable by definition?

3 Likes

According to my knowledge, this does not apply to protocols. Global actor isolated protocols just means the requirements need to be isoalted to that actor, not the type itself:

@MainActor
protocol P1 {
    var a: Int { get set }
}
// Means:*
protocol P1 {
    @MainActor var a: Int { get set }
}

For example:

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

@MainActor
protocol P1 {
    var a: Int { get set }
}

@DatabaseActor
protocol P2 {
    var b: Int { get set }
}

final class P1P2Conformer: P1, P2 {
    var a: Int = 1 // isolated to MainActor
    var b: Int = 2 // isolated to DatabaseActor
    var c: Int = 2 // nonisolated**
}

*: There is a small difference between the two. The first one - if there are no other protocols like this that are isoalted to a different global actor - will make the entire type isolated to it. The second doesn't.

**: Normally the isolation would've been inherited from the conformance and make this also isolated. But since there are 2 different isoaltion domains the compiler makes it nonisoalted.


Also the requirements can be satisfied with nonisolated members:

nonisolated final class NonisolatedP1P2Conformer: P1, P2 {
    var a: Int = 1
    var b: Int = 2
}
3 Likes

The protocol P itself does not become isolated to @MainActor. All it does is make any members of P @MainActor isolated by default, but not the protocol itself. Ms. Borla already gave an excellent answer in this thread: Global actor isolated protocols and SendableMetatype - #2 by hborla

5 Likes

This is tricky one. But it helped me quite a bit to see a concrete example:

@MainActor
protocol P {}

class NonSendable {
  var state = 1
}

extension NonSendable: P {
}
3 Likes
  1. Make protocol P inherit from Sendable if you want it to be sendable.
  2. Even if it's sendable, @MainActor still ensures its properties (aka its members) are main-actor isolated.

This example should help clarify:

@MainActor
protocol P: Sendable {
    var x: Int { get set } // @MainActor applies here
}

@MainActor
class C {
    let p: any P
    let x: Int
    
    nonisolated init(p: any P) {
        self.p = p
        self.x = p.x  // ❌ Main actor-isolated property 'x' 
                      // can not be referenced from a nonisolated context
    }
}
2 Likes

I think errors in these examples like:

Main actor-isolated property 'p' can not be mutated from a nonisolated context

or

Main actor-isolated property 'x' can not be referenced from a nonisolated context

are misleading. It's possible to set property x in your example in a nonisolated init. This compiles:

  @MainActor
  protocol P: Sendable {
      var x: Int { get set } // @MainActor applies here
  }

  @MainActor
  class C {
      let p: any P
      let x: Int
    
-     nonisolated init(p: any P) {
+     nonisolated init(p: any P) async {
          self.p = p
-         self.x = p.x
+         self.x = await p.x
      }
  }
1 Like

A tangentially related question (it uses a similar setup as OP's example, but not about OP's question). This doesn't compile. It's as expected because a function in another isolated domain is allowed to call this nonisolated init.

class NS {}

@MainActor
class C {
    let ns: NS
    
    nonisolated init(ns: NS) {
        self.ns = ns
    }
}

But this doesn't compile either. It that by design?

  class NS {}
  
  @MainActor
  class C {
      let ns: NS
      
-     nonisolated init(ns: NS) {
+     nonisolated init(ns: sending NS) {
          self.ns = ns
      }
  }

EDIT: on a second thought, I'm not sure about the second example. Is it OK for a nonisolated synchronous function to have sending parameters?

Another observation. It's well known that an existential value any P doesn't conform to P. So when I saw this code, I wasn't sure if it would compile.

protocol P: Sendable {}

@MainActor
class C {
    let p: any P
    
    nonisolated init(p: any P) {
        self.p = p
    }
}

It compiles. I can understand it because Sendable is a mark protocol. Just curious, does it mean compiler has implemented a hardcoded exception for this?

1 Like

Yeah it'd be helpful if the errors explained that async is not a reference.