swiftswift-concurrencydata-raceswift6

Passing closure risks causing data races


I know this sort of thing has been asked and answered before — see, for example, Swift 6 warnings "Passing closure as a 'sending' parameter risks causing data races" — but I still don't understand it. This simple example causes a compile-time error in Swift 6:

class MyClass {
    private var myProperty: String = ""
    func myFunc() {
        Task {
            // Passing closure as a 'sending' parameter risks causing data races
            // between code in the current task and concurrent execution of the closure
            myProperty = "test"
        }
    }
}

If I mark MyClass as @MainActor, the error goes away. Really? Why? What difference does that make in whether or not there is a possibility of a data race? What could happen in the code as I have it that could not happen if the class is isolated to the main actor?


Solution

  • By accessing myProperty, the Task closure captures self. The Task closure can run concurrently with other code that have access to self. In other words, there is now a non-sendable value (self) being shared by code that runs concurrently. The access to self in the task could race with whatever other access to self that happens elsewhere.

    let x = MyClass()
    x.myFunc() // this creates a task
    x.myFunc() // this creates another task that can be executed concurrently with the first one!
    

    The purpose of any actor, not just MainActor, is to protect mutable state from data races by serialising accesses (make the accesses occur one after another, instead of concurrently) to that state using a serial executor.

    By isolating MyClass to MainActor, self is protected by an actor. Any access to self will be serialised and they will never happen concurrently.

    An additional effect of isolating MyClass to the main actor is that the Task is also isolated to the main actor. This is because Task.init creates a task that has the same isolation as the current code (in this case it is myFunc, which of course is also isolated to the main actor). This is why you can access myProperty synchronously in the task, without needing to "hop" onto the main actor by awaiting the operation.

    Compare this to a detached task, which does not have the same isolation as the current code. You'd need to do

    @MainActor
    class MyClass {
        private var myProperty: String = ""
        func myFunc() {
            Task.detached {
                await MainActor.run {
                    self.myProperty = "Foo"
                }
            }
        }
    }
    

    It is also possible to only isolate myProperty. In this case, MyClass can be sendable if you also make it final, since all its mutable properties are protected by an actor. Again, the task will not be isolated to the main actor, so you also need a hop.

    final class MyClass: Sendable {
        @MainActor
        private var myProperty: String = ""
        func myFunc() {
            Task {
                await MainActor.run {
                    self.myProperty = "Foo"
                }
            }
        }
    }