Working correctly with actor annotated class

Hi,
I have a complex structure of classes, and I'm trying to migrate to swift6

For this classes I've a facade that creates the classes for me without disclosing their internals, only conforming to a known protocol

I think I've hit a hard wall in my knowledge of how the actors can exchange data between themselves. I've created a small piece of code that can trigger the error I've hit

import SwiftUI
import Observation

@globalActor
actor MyActor {
    static let shared: some Actor = MyActor()

    init() {

    }
}

@MyActor
protocol ProtocolMyActor {
    var value: String { get }

    func set(value: String)
}

@MyActor
func make(value: String) -> ProtocolMyActor {
    return ImplementationMyActor(value: value)
}

class ImplementationMyActor: ProtocolMyActor {
    private(set) var value: String

    init(value: String) {
        self.value = value
    }

    func set(value: String) {
        self.value = value
    }
}

@MainActor
@Observable
class ViewObserver {
    let implementation: ProtocolMyActor

    var value: String

    init() async {
        let implementation = await make(value: "Ciao")
        self.implementation = implementation

        self.value = await implementation.value
    }

    func set(value: String) {
        Task {
            await implementation.set(value: value)
            self.value = value
        }
    }
}

struct MyObservedView: View {
    @State var model: ViewObserver?
    
    var body: some View {
        if let model {
            Button("Loaded \(model.value)") {
                model.set(value: ["A", "B", "C"].randomElement()!)
            }
        } else {
            Text("Loading")
                .task {
                    self.model = await ViewObserver()
                }
        }
    }
}

The error

Non-sendable type 'any ProtocolMyActor' passed in implicitly asynchronous call to global actor 'MyActor'-isolated property 'value' cannot cross actor boundary

Occurs in the init on the line "self.value = await implementation.value"

I don't know which concurrency error happens... Yes the init is in the MainActor , but the ProtocolMyActor data can only be accessed in a MyActor queue, so no data races can happen... and each access in my ImplementationMyActor uses await, so I'm not reading or writing the object from a different actor, I just pass sendable values as parameter to a function of the object..

can anybody help me understand better this piece of concurrency problem?
Thanks

I quickly put your sample code into a playground and saw another error as well, in init on the call to make:
Non-sendable type 'any ProtocolMyActor' returned by call to global actor 'MyActor'-isolated function cannot cross actor boundary.

This is kind of expected, imo, as you create a non-Sendable object in the @MyActor-isolated domain (since make is isolated to that), but you do so from within the @MainActor-isolated domain of init. Since the returned value [1] is not Sendable, that results in an error.

TL:DR: Note that the isolation as defined does not just isolate the properties and functions of the type, it isolates the type itself, i.e. as long as it is not Sendable, you basically tell the compiler "it is not safe to pass objects of this type around between isolation domains".[2]

Normally you could fix this by making the return value of make sending:

@MyActor
func make(value: String) -> sending any ProtocolMyActor {
    return ImplementationMyActor(value: value)
}

That resolves the first error, but unfortunately not the second one and you get stuck with that, too.

Your implementation object was now transferred to @MainActor's isolation domain, but there you store it in a property. That means your ImplementationMyActor owns it and thus is permanently belongs to that isolation domain.
Since its own properties and functions, however, are isolated to MyActor, you cannot do anything with them. The reason for that is that any operation (getting a property, running an instance function) requires self, so the object needs to pass an isolation domain again.
While you might think that's fine as all its properties and functions are isolated to MyActor and thus everything is "queued", this does not, I believe, eliminate every racing condition possible.
Theoretically it could be possible something else happens to it while it's used "on the other isolation domain", e.g. it could be destroyed (I think, maybe somebody else can jump in here?).

Would you not store implementation, you could write a generic helper function with a sending parameter that returns the value again, but that obviously runs counter to your design.

The easiest fix I see (I think) is to mark ProtocolMyActor Sendable, because that's what you want: An object that you can keep in one isolation domain, but safely send to another to retrieve values from it. I realize that's probably hard in a real app as adopting classes might contain mutable state, etc., but I'm not sure there's a way around that.


Side note: While playing around with this a bit I noticed that

@MyActor
protocol ProtocolMyActor: Sendable {
    var value: String { get }
    func set(value: String)
}

resolves the error immediately, although the adopting class ImplementationMyActor is not final and does contain mutable state! I think this is a missing diagnostic, no?
Using

protocol ProtocolMyActor: Sendable {
    @MyActor
    var value: String { get }
    @MyActor
    func set(value: String)
}

results in the diagnostic for ImplementationMyActor I'd expect: "on-final class 'ImplementationMyActor' cannot conform to 'Sendable'; use '@unchecked Sendable'".


  1. of type any ProtocolMyActor, btw, I would encourage everybody to favor the explicit syntax for existentials instead of the "old" way of just using the protocol's identifier ↩︎

  2. With the exception of using sending and region-based isolation, which means "you can pass it to another isolation domain if it's no longer used in the current one" ↩︎

Thanks for you great response Gero!

Concurrency is very hard to understand, and your explanation make me realise that there is a difference between the boundaries... I thought it was just a queue problem but it is not

BTW after I've wrote the post, I found this other topic Use a `Protocol` of @MainActor instead of concrete @MainActor class produces an error - #3 by MasterWatcher

And in this topic the users make your same discovery, that setting :Sendable on the protocol will resolve issues, and indeed it does, as you have found out!

Still we cannot understand if it is a diagnostic problem or a true resolution

because it should not be correct, but by spelunking on the Apple docs I've read that annotating a class with "@MainActor" will make the class implicitably sendable

So maybe this :Sendable on a protocol will just enforce this implicit behaviour? I don't know

Ah, yes, that is related and might explain the thing I noticed in my side note.
I believe if the entire protocol is @MyActor, that also makes the entire adopting type (ImplementationMyActor in your case) isolated to @MyActor and that might result in implicit Sendable conformance of the class without the usual "use @unchecked..." diagnostic... I am not actually sure about this, though.
If you only isolate the individual property and function requirements in the protocol, I think only those become isolated in the adopting type, and if the protocol also requires it be Sendable the type has to provide that on its own (which for classes then does require @unchecked...).

Anyway, apparently marking the entire protocol @MyActor (this "hiding"? the @unchecked diagnostic) does not allow the compiler to deduce that any ProtocolMyActor is Sendable, though, that still requires the protocol to explicitly extend Sendable.


FWIW:
Despite my long posts I am not a real expert on concurrency, so someone else would need to pitch in here.

In general, keep in mind that putting an isolation annotation to a type and extending a protocol with Sendable are not the same:
If you mark a class with @MainActor you change its implementation. All its members become isolated and as a result, it also becomes (implicitly) Sendable.
Writing : Sendable after a protocol's name means that your protocol is an extension of Sendable. That means that every type that adopts your protocol, will also have to adopt the Sendable protocol, but they have to do so somehow themselves. And if your adopting type is a non-final class and/or has mutable state, you have to do so using @unchecked and implement synchronization functionality that protects it from data races manually (e.g. use locks).


Oh, one more thing: You do realize that your class basically tries to become an actor itself here, right? While I think using actors everywhere simply for the sake of it is not in Swift concurrency's intention, is there any specific reason why you are not using them directly here to protect your mutable state?

Yah I know that, and your perplexity is correct, why am I using this strategy?

Honestly I'm just trying to dip my water in this big can of warm called "swift6", I've something like 15 classes that talk between each others, because they are just a facade to low level libraries that must be used in a specific way (for example some of that must be used on MainThread, the library is a wrapper of AVFoundation stuff)

So having 15 actors I thought it was not a good idea, and honestly I'd like to reduce to a minimum the apis that needs "await"

But more I'm going deep in this hole, more I think that execution suspensions are just the bread and butter of working with swift6

And of course all this classes must be, at the end of the chain, be used by a SwiftUI view, which calls must be on @MainActor

So the answer I'm just researching on how to harm myself while studying all this new stuff ahah

I feel ya, been there myself! :sweat_smile:

While I myself lack the depth to judge whether "15 actors" are too much or not, I think especially when learning this it might be worthwhile to just try it and see for yourself. Premature optimization is not a good tactic when you try to familiarize yourself with new APIs.

Generally speaking I believe so far the new concurrency world often times warrants a re-evaluation of entire architecture patterns you might have used in the past. It may look a lot of work, but might just be worth it in the longer run (and I have also been surprised how quickly it went into a manageable amount myself).

When trying to wrap older APIs into the mix, you may be interested in checking out things like the relatively new Mutex in the Synchronization module. I haven't used it myself and since it is a blocking lock that would effectively block tasks on concurrent accesses I think it makes it easier to migrate code into the "new world". Especially in an example like above, where you just "quickly get a string" such blocking is probably okay in many, many cases.