This code can be compiled as command line tool for macOS.
import Foundation
@main
struct App {
static var counter = 0
static func main() async throws {
print("Thread: \(Thread.current)")
let task1 = Task { @MainActor () -> Void in
print("Task1 before await Task.yield(): \(Thread.current)")
await Task.yield()
print("Task1 before await increaseCounter(): \(Thread.current)")
await increaseCounter()
print("Task1 after await increaseCounter(): \(Thread.current)")
}
let task2 = Task { @MainActor () -> Void in
print("Task2 before await Task.yield(): \(Thread.current)")
await Task.yield()
print("Task2 before await decreaseCounter(): \(Thread.current)")
await decreaseCounter()
print("Task2 after await decreaseCounter(): \(Thread.current)")
}
_ = await (task1.value, task2.value)
print("Final counter value: \(counter)")
}
static func increaseCounter() async {
for i in 0..<999 {
counter += 1
print("up step \(i), counter: \(counter), thread: \(Thread.current)")
await Task.yield()
}
}
static func decreaseCounter() async {
for i in 0..<999 {
counter -= 1
print("down step \(i), counter: \(counter), thread: \(Thread.current)")
await Task.yield()
}
}
}
My understanding is:
static func main() async throws
inherits MainActor async context, and should always run on the main thread (and it really seems that it does so)Task
is initialized by the initializer, so it inherits the actor async context, so I would expect that will run on the main thread. Correct?- Moreover, the closure for
Task
is annotated by@MainActor
, so I would even more expect it will run on the main thread. - I would expect that
static func main() async throws
inherits MainActor async context and will prevent data races, so the final counter value will always be zero. But it is not. - Both task1 and task2 really start running on the main thread, however the async functions
increaseCounter()
anddecreaseCounter()
run on other threads than the main thread, so the Task does not prevent data races, while I would expect it. - When I annotate
increaseCounter()
anddecreaseCounter()
by@MainActor
then it works correctly, but this is what I do not want to do, I would expect thatTask
will do that.
Can anyone explain, why this works as it does, please?
So, there are two parts to this:
-
Why does it behave the way it does?
-
Why doesn’t Swift catch the problem?
The answer to the second is that the current Swift compiler only has limited concurrency checking and, even if you enable all the checks, it’s not very good about checking global variables (which is what App.counter
is because it’s static
).
If you compile your code with Xcode 14.0b3 and enable the Strict Concurrency Checking build setting (set SWIFT_STRICT_CONCURRENCY
to complete
), you’ll get a bunch of concurrency warnings.
As to why it works the way that it does, the key thing to note is that an async
function can run in any context unless it’s explicitly isolated to an actor. Your increaseCounter()
and decreaseCounter()
functions are static
, and so are effectively the same as free functions. These are not isolated to an actor and thus there’s nothing stopping them from running in parallel.
If you want to force them to run on the main actor, you can add the @MainActor
attribute (which you’ve already tested) or move them, and counter
, to an actor, removing the static
for all three, and then annotate that with @MainActor
.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"