Calling non-isolated async method from a child task inheriting global actor allows for a race condition in Swift 6

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!

3 Likes

I've also found an issue on GitHub that seems to be related.

1 Like

I agree with you, I also believe there's definitely a safety hole in the current RBI implementation.

1 Like

Thanks for confirming @CrystDragon, let’s hope it’s fixed soon.

It’s unfortunate, because if someone already started migrating to Swift 6 and worked around the errors it’s quite possible they work will be partially wasted. As conformance to Sendable is a strong requirement and where possible I wanted to avoid it, trusting in and benefiting from RBI, I've already added some code that passed the static checks, but probably won't when the compiler will be fixed.

And this seems to me like the biggest problem - after updating the toolchain "migrated" projects may stop compiling.

1 Like

It's pretty similar to Sendable checking hole when actors call non-mutating async methods on non-Sendable types · Issue #65315 · swiftlang/swift · GitHub, which was marked fixed a long time ago. That bug also references How can self be accessible in a non-isolated async function of a non-Sendable type? · Issue #71097 · swiftlang/swift · GitHub, also marked closed, but I don't actually believe @hborla 's comment on this one — the async on the mutating method means there's always an actor hop to call it. And an async method on a non-Sendable class (as per OP here) is essentially the same problem.

FWIW, Holly has a proposal to change this default behavior of hopping actor at async: [Pitch] Inherit isolation by default for async functions which would actually resolve this issue, but obviously that can't take effect before Swift 7, so this should be fixed now for Swift 6...

1 Like

No, this is not the same as that older issue that I closed as correct behavior. In this example, the non-Sendable values are captured in isolated Task closures, which should be considered a region merge under region-based isolation, which should prevent the nonisolated async function from being called. I still believe my answer on that GitHub issue is correct. It is safe to call a nonisolated async method on a non-Sendable type if the caller is already on the generic executor, or if the non-Sendable value is in a disconnected region.

5 Likes