Search code examples
swiftswift-concurrency

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


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.


Solution

  • The error is warning you that your class has 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
    }
    

    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.)

    In short, you have marked the as @MainActor, but there is nothing to stop other threads from interacting with this property of the class directly. For a type to be Sendable, it must either:

    • be immutable;
    • manually synchronize its properties; or
    • be an actor

    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)
            }
        }
    }