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

  • This message is warning you that your class has a mutable property without any synchronization. So, prior to Xcode 16, this would result in the warning you shared with us.

    Xcode 16 improves the situation notably. You will not even see the warning in your declaration of Foo because:

    In short, Xcode 16 moves the warning from the property declaration (which was overly cautious) to the the point of misuse, if any.


    The reason for this warning is that in Swift 5.x, this is not, strictly speaking, thread-safe (despite the @MainActor designation). Yes, if you attempt to mutate it from Swift concurrency context, the @MainActor qualifier will ensure static data-race safety. But, in Xcode 15.x, this is not actually thread-safe because you could mutate it unsafely using legacy techniques such as GCD.

    Consider the following in Xcode 15.x (with “Strict concurrency checking” set to “Minimal”):

    final class Foo: Sendable {
        @MainActor var counter = 0  // In Xcode 15.x: Stored property 'counter' of 'Sendable'-conforming class 'Foo' is mutable
    }
    
    func incrementFooManyTimes() {
        let foo = Foo()
    
        DispatchQueue.global().async {
            DispatchQueue.concurrentPerform(iterations: 10_000_000) { _ in
                foo.counter += 1    // this is not thread-safe, but in Xcode 15.x, it will not warn you unless you set “Strict concurrency checking” to “Complete”
            }
            print(foo.counter)      // 6146264 !!!
        }
    }
    

    This is why, in Xcode 15.x, at least, it was important to have that warning in the declaration of Foo. This is also why we constantly encourage developers to refrain from casually intermingling GCD with Swift concurrency code bases; there are only a very few, very specific situations where you would do so.

    Needless to say, if you turn on strict concurrency checking and/or use Xcode 16, you will receive the appropriate warning:

    enter image description here

    In short, back in the day, although you have marked the as @MainActor, but there was actually nothing to stop other threads from interacting with this property of the class directly from GCD (in Xcode 15.x, at least). Historically, for a type to be Sendable, it must either:

    As a final disclaimer, if you are interacting with this property from Objective-C (assuming you added the appropriate @objc qualifiers), the problem is even worse. Objective-C cannot reason about the static data-race safety offered by Swift concurrency. If your types are going to be accessed from Objective-C, it really is important to either rely on immutability, or employ old-school synchronizations, not Swift concurrency.


    The safest way to make a non-actor-isolated type Sendable was 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)
            }
        }
    }