Hi all The following snippet (a quick attempt at parallelizing some CPU-intensive computation) has an error related to capturing a Mutex in a sending closure (the addTask { ... } closure).
I don't fully understand why this is an issue in the first place:
struct Foo {
static func cpuIntensiveWork() async -> Int {
try! await Task.sleep(for: .seconds(5))
return Int.random(in: 0...10)
}
func parallelComputation() async -> Int {
let fullResult = Mutex<Int>(0)
await withTaskGroup(of: Void.self) { taskGroup in
taskGroup.addTask { // β Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
// Note: closure captures 'fullResult' which is accessible to code in the current task
let localResult = await Self.cpuIntensiveWork()
fullResult.withLock { state in
state += localResult
}
}
}
return fullResult.withLock { $0 }
}
}
Here fullResult is Sendable, and no other values are captured, which I think means (and this is maybe where my understanding is flawed) the closure is safe to use from any context, and therefore sendable, so it should be fit to be passed to a sending closure parameter. The compiler doesn't seem to be able to realize this though. Explicitly annotating the closure with @Sendable seems to work:
func parallelComputation() async -> Int {
let fullResult = Mutex<Int>(0)
await withTaskGroup(of: Void.self) { taskGroup in
taskGroup.addTask { @Sendable in // β
let localResult = await Self.cpuIntensiveWork()
fullResult.withLock { state in
state += localResult
}
}
}
return fullResult.withLock { $0 }
}
But I'm curious, why is the explicit @Sendable annotation required? AFAICT, capturing other sendable values is fine.
(I know there are better methods than Mutex for the code shown here, this is just a tiny example).
now, on to my speculations as to what's going on here...
note that this behavior is not limited to Mutex; you can end up with the same diagnostic from this reduced example with a subset of the Mutex API:
struct NC: ~Copyable {}
extension NC: @unchecked Sendable {}
func f() async {
let nc = NC()
await withTaskGroup(of: Void.self) { taskGroup in
taskGroup.addTask {
`- error: passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
_ = nc
`- note: closure captures reference to mutable let 'nc' which is accessible to code in the current task
}
}
}
now, removing the ~Copyable suppression syntax resolves the issue[1], so presumably the diagnostic has something to do with the type being non-copyable.
consider a similar configuration, where we have a mutable capture of a Sendable type, e.g.
func f() async {
var i = 0
await withTaskGroup(of: Void.self) { taskGroup in
taskGroup.addTask {
`- error: passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
_ = i
`- note: closure captures reference to mutable var 'i' which is accessible to code in the current task
}
}
}
the diagnostics are essentially identical, which suggests the region-based isolation checking is perhaps treating these two cases as equivalent. i can imagine why this might happen; a mutable capture of a Sendable type is sort of like wrapping the sendable entity in a (non-sendable) 'box' of sorts. similarly, a non-copyable type can perhaps be thought of as a value contained in a 'mutable box', with some additional requirements about how things can move in and out of it.
it seems like this is probably a bug β a Sendable type (and in particular Mutex) should be able to be passed across these isolation boundaries without issue.
I was just surprised to hit this myself when migrating from Atomics.ManagedAtomic to Synchronization.Atomic:
import Atomics
func Ζ() async {
let v = ManagedAtomic(false)
await withDiscardingTaskGroup { taskGroup in
taskGroup.addTask { // β
v.store(true, ordering: .releasing)
}
}
}
import Synchronization
func Ζ() async {
let v = Atomic(false)
await withDiscardingTaskGroup { taskGroup in
taskGroup.addTask { // π Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
v.store(true, ordering: .releasing)
}
}
}
Marking the closure passed to taskGroup as @Sendable does indeed work around the issue, but I'm surprised the compiler doesn't somehow "forward" the borrow as-is:
func call(_ body: sending @escaping () async -> Void) async {
await body()
}
func Ζ1() async {
let v = Atomic(false)
// a single layer works
await call { // β
v.store(true, ordering: .releasing)
}
}
func Ζ2() async {
let v = Atomic(false)
// nested `sending` does not...
await call {
await call { // π Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure
v.store(true, ordering: .releasing)
}
}
}
func Ζ3() async {
let v = Atomic(false)
// ... unless the inner closure is explicitly `@Sendable`
await call {
await call { @Sendable in // β
v.store(true, ordering: .releasing)
}
}
}
Is this something that should be expected to work (eventually), or should the diagnostics here be made more explicit? (/cc @Joe_Groff, maybe?)