Hi! I might have found a possible bug in the compiler, as it seems to allow for a race condition to be created in the Swift 6 language mode, but my understanding could be wrong. I would highly appreciate any insights on the matter.
With the new Swift 6 language mode (tested with Xcode 16.0 and 16.1 beta 3) I'm able to compile the following code without warnings:
final class DataSource {
func fetch() async -> [String] {
[String(Int.random(in: 0 ..< 100))]
}
}
final class ViewModel {
let dataSource = DataSource()
var data = [String]()
@MainActor
func loadInBackground() {
Task { await load() }
}
func load() async {
data = await dataSource.fetch()
print(data)
}
}
// MARK: - Demo
// Just for reproduction purposes, the problem is not SwiftUI-specific
import SwiftUI
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
Color.blue.onAppear {
for _ in 0 ..< 10000 {
viewModel.loadInBackground()
}
}
}
}
With multiple rapid calls to loadInBackground()
multiple tasks are created that access and modify the mutable state, but since the load()
method is non-isolated and per SE-0338 it switches from the parent task's MainActor
executor to a generic one, a race condition is created.
If I modify the loadInBackground
method not to be isolated to any global actor, the compiler produces the following error:
func loadInBackground() {
Task { await load() }
// Xcode 16.1 beta 3 - Error: Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
// Xcode 16.0 - Error: Task-isolated value of type '() async -> ()' passed as a strongly transferred parameter; later accesses could race
// Side note - it's great to see error messages improve!
}
That's indeed correct and I would suspect a similar error should be emitted when the function was marked with a global actor.
Moreover, let's consider the following example, which also compiles without issue in Swift 6:
Task { @MainActor in
let viewModel = ViewModel()
Task {
for _ in 0 ..< 10000 {
await viewModel.load()
}
}
for _ in 0 ..< 10000 {
await viewModel.load()
}
}
While both loops are performed in the same isolation context inherited from the top-level task, the moment we enter the load()
method the execution is switched to a generic executor, leading to simultaneous state mutation from two call sites.
When the @MainActor
attribute is removed, both Xcode 16.0 and 16.1 beta 3 give the same error:
Error: Value of non-Sendable type '@isolated(any) @async @callee_guaranteed @substituted <τ_0_0> () -> @out τ_0_0 for <()>' accessed after being transferred; later accesses could race
None of this was possible in Swift 5.10 with strict concurrency checking enabled, as it was before the introduction of region-based isolation. I'm lead to believe the static isolation checking rules applied to tasks inheriting global actor context from their parents could be too lenient.
Again, I could be wrong about this, as I'm still reading through the evolution proposals and articles from the community in an attempt to understand Swift's concurrency system better. All help is welcome!