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?
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 await
ing 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"
}
}
}
}