Home > database >  How come a MainActor isolated mutable stored property gives a sendable error?
How come a MainActor isolated mutable stored property gives a sendable error?

Time:11-16

I'm trying to conform a class to Sendable. I have some mutable stored properties which are causing issues. However, what I can't understand is that a MainActor isolated property doesn't allow my class to conform to Sendable. However, if I mark the whole class a @MainActor, then it's fine. However, I don't actually want to conform the whole class to @MainActor.

As an example, take this code:

final class Article: Sendable {
  @MainActor var text: String = "test"
}

It gives this warning: Stored property 'text' of 'Sendable'-conforming class 'Article' is mutable.

Can someone explain why? I thought that being isolated to an actor would make it fine.

CodePudding user response:

Reading the Swift Evolution proposals, it seems like complex Sendable conformance checking just hasn't been designed/implemented yet.

From the global actors proposal, it is said that types which are marked with a global actor implicitly conform to Sendable.

A non-protocol type that is annotated with a global actor implicitly conforms to Sendable. Instances of such types are safe to share across concurrency domains because access to their state is guarded by the global actor.

So you don't even need : Sendable if you mark your final class with @MainActor!

On the other hand, the proposal for Sendable mentions:

Sendable conformance checking for classes

[...] a class may conform to Sendable and be checked for memory safety by the compiler in a specific limited case: when the class is a final class containing only immutable stored properties of types that conform to Sendable:

final class MyClass : Sendable {
    let state: String
}

Basically, every stored property in your class need to be a let if you don't mark your final class with a global actor.

I have no been able to find anything else in these two documents, or other proposals on Swift Evolution that are relevant.

So the current design doesn't even care about whether you add @MainActor to a property. The two sufficient conditions for a final class to conform to Sendable

The Sendable proposal also mentions:

There are several ways to generalize this in the future, but there are non-obvious cases to nail down. As such, this proposal intentionally keeps safety checking for classes limited to ensure we make progress on other aspects of the concurrency design.

CodePudding user response:

The error is warning you that your class is exposing a mutable property. That mutable property can be accessed from outside of Swift concurrency and is therefore not safe.

Consider the following:

final class Foo: Sendable {
    @MainActor var counter = 0   // Stored property 'counter' of 'Sendable'-conforming class 'Foo' is mutable
}

Anyway, we can now consider the following property and method of a view controller, which interacts with counter directly:

let foo = Foo()

func incrementFooManyTimes() {
    DispatchQueue.global().async { [self] in
        DispatchQueue.concurrentPerform(iterations: 10_000_000) { _ in
            foo.counter  = 1
        }
        print(foo.counter)   // 6146264 !!!
    }
}

NB: If you do have set the “Swift Concurrency Checking” build setting to “Minimal” or “Targeted”, the above will compile with only the aforementioned warning. (If you change this to “Complete”, it becomes a hard error.)

Anyway, in short, if you have marked the as @MainActor, but there is nothing to stop other threads from interacting with this property of the class directly.


If you are going to have a non-actor be Sendable with mutable properties, you have to implement the thread-safety yourself. E.g.:

final class Foo: @unchecked Sendable {
    private var _counter = 0
    private let queue: DispatchQueue = .main    // I would use `DispatchQueue(label: "Foo.sync")`, but just illustrating the idea

    var counter: Int { queue.sync { _counter } }

    func increment() {
        queue.sync { _counter  = 1 }
    }
}

And

func incrementFooManyTimes() {
    DispatchQueue.global().async { [self] in
        DispatchQueue.concurrentPerform(iterations: 10_000_000) { _ in
            foo.increment()
        }
        print(foo.counter)   // 10000000
    }
}

Obviously, you could also restrict yourself to immutable properties and no synchronization would be necessary. But I assume you needed mutability.

Now, in this mutable scenario, you can use whatever synchronization mechanism you want, but hopefully this illustrates the idea. In short, if you are going to allow it to mutate outside of Swift concurrency, you have to implement the synchronization yourself. And because we are implementing our own synchronization, we tell the compiler that it is @unchecked, meaning that you are not going to have the compiler check it for correctness, but rather that burden falls on your shoulders.


Obviously, life is much easier if you use an actor and stay within the world of Swift concurrency. E.g.:

actor Bar {
    var counter = 0

    func increment() {
        counter  = 1
    }
}

And:

let bar = Bar()

func incrementBarManyTimes() {
    Task.detached {
        await withTaskGroup(of: Void.self) { group in
            for _ in 0 ..< 10_000_000 {
                group.addTask { await self.bar.increment() }
            }
            await print(self.bar.counter)
        }
    }
}
  • Related