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

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