Potential false positive `sending '...' risks causing data races`

I can't wrap my head around the following example:

class NonSendable {}

@MainActor
func main1() async {
    let ns = NonSendable()
    await foo(ns)
    await foo(ns)
}

@MainActor
func main2() async {
    let ns = NonSendable()
    await { await foo(ns) }() // error: sending 'ns' risks causing data races
    await runBlock { await foo(ns) } // error: sending 'ns' risks causing data races
}

func foo(_ a: Any) async {}

func runBlock(isolation: isolated (any Actor)? = #isolation, _ block: () async -> ()) async {
    await block()
}

main1 is green but main2 is red. I want to create a "transparent" runBlock function that just calls its closure, but I have no luck because of the error.

If I drop @MainActor from main2, then the diagnostic disappears. I use Swift 6.0.3.

Apparently, Swift doesn't have problems with sending ns from main2 to the closure. It has problems with sending ns from the closure to await foo. But if it's the case then main1 shouldn't work, should it?

Right now, I tend to think that it's a false positive diagnostic, but I may be missing something

2 Likes

FWIW, my workaround is like this:

@MainActor
func main3() async {
    nonisolated(unsafe) let ns = NonSendable()
    await { @Sendable in await foo(ns) }()
    await runBlock { @Sendable in await foo(ns) }
}
  • Declare ns with nonisolated(unsafe)
  • Make every closure @Sendable explicitly.

I'm not sure if it's safe, though.

I found the workaround to just declare runBlockMainActor:

class NonSendable {}

@MainActor
func main2() async {
    let ns = NonSendable()
    await runBlockMainActor { await foo(ns) }
    await runBlockMainActor { await foo(ns) }
}

func foo(_ a: Any) async {}

@MainActor
func runBlockMainActor(_ block: @MainActor () async -> ()) async {
    await block()
}

I tried to declare runBlockMainActor as generic, but unfortunately it doesn't work: False positive `sending '...' risks causing data races`. Global isolation vs parameter isolation. Actor as generic · Issue #80368 · swiftlang/swift · GitHub

I think the compiler erroneously deduced the { await foo(ns) } closure to be a @MainActor one, thus transfering ns into the MainActor domain.

I've found 2 different ways to bypass this problem, without leveraging unsafe features. Both work by keeping ns out of the MainActor region.

  1. use a local function, which is explicitly marked nonisolated
@MainActor
func main2() async {
    let ns = NonSendable()
    nonisolated func block() async {
        await foo(ns)
    }

    await block()
}
  1. mark the closure parameter in runBlock as sending:
@MainActor
func main2() async {
    let ns = NonSendable()
    await runBlock { await foo(ns) }
}

func runBlock(isolation: isolated (any Actor)? = #isolation, _ block: sending () async -> ()) async {
    await block()
}

Unfortunately, the suggested solutions don't work

  1. is no different from main1 in the original post
  2. doesn't work when you use ns multiple times:
@MainActor
func main2() async {
    let ns = NonSendable()
    await runBlock { await foo(ns) } // error: value of non-Sendable type '@noescape @async @callee_guaranteed () -> ()' accessed after being transferred; later accesses could race
    await runBlock { await foo(ns) }
}

Yes, method 2 has its own requirement. But why do you say method 1 is no different?

You can write

@MainActor
func main2() async {
    let ns = NonSendable()
    nonisolated func block() async {
        await foo(ns)
    }

    await runBlock(block)
    await runBlock(block)
}

func foo(_ a: Any) async {}

func runBlock(isolation: isolated (any Actor)? = #isolation, _ block: () async -> ()) async {
    await block()
}

Good point, thanks, now I agree.

Actually, now when nonisolated is explicitly spelled out, the whole situation confuses me even more. So Swift doesn't complain when it's nonisloated, but it does complain when func block() is isolated to @MainActor.

Isn't the isolation to @MainActor better than being nonisolated? In case of isolation to @MainActor, ns doesn't cross any isolation domains, it always stays in the MainActor isolation domain -- there are no races.

I am confused...

Uh, this is tricky, but I think I know what's going on here.

By using isolation: isolated (any Actor)? = #isolation, you're telling the compiler that runBlock runs in the same isolation domain as the caller. However (and this is the tricky bit), the compiler doesn't know that block should also run on the same isolation domain as the caller.

This is more or less the exact issue described as one of the motivation examples for Run nonisolated async functions in the caller actor by default (SE-0461). Another clue that this is the underlying issue is that annotating block with the (underscored, not stable) attribute @_inheritActorContext works:

func runBlock(
    isolation: isolated (any Actor)? = #isolation,
    @_inheritActorContext _ block: () async -> ()
) async {
    await block()
}

So the compiler sees that runBlock has no specific isolation requirements for its block parameter, therefore it must do sendability checks for the closure. And then it detects that the closure you're passing to block includes a non-sendable value.

The only way to pass that non-sendable value across isolation domains is by sending it. But although sending works in a function, for example in your main1 function:

@MainActor
func main1() async {
    let ns = NonSendable()
    await foo(ns)
    await foo(ns)
}

It doesn't work for the closure. Generally speaking, sending a value to a function as an argument is more reliable than creating a closure where that same value is sent to the function and passing that closure as an argument to a function.

That's because the function that receives the closure is allowed (depending on a combination of other factors, like whether the closure is @escaping/async/@Sendable) to do weirder things with the closure, like calling it more than once or calling it multiple times in parallel which may mess up the logic of whether some value can be sent.

2 Likes