Why does `Mutex` let me send non-`Sendable` types?

Why does this code snippet work and allow me to access a non-Sendable type outside of where it should be accessible?

import Synchronization

final class NS {
	var value: Int = 0
	init(_ value: Int = 0) { self.value = value }
}


let ns = NS()
let lock = Mutex(ns)

await withTaskGroup { taskGroup in
	for _ in 0..<5000 {
		taskGroup.addTask {
			let ns = lock.withLock {
				$0 = NS($0.value)
				return $0
			}
			ns.value += 1
		}
	}
}

print(lock.withLock { $0.value })

The code above is printing values less than 5000 (usually around 3000, nondeterministically) implying there are a lot of data races in which mutations are being dropped.

I would expect there to be a warning because withLock is both returning a non-Sendable value and allowing it to be stored back in the mutex from where it came. Since NS is not-Sendable, but it’s also an inout value, it’s implicitly getting “returned” back into the mutex, but then also letting me return it to the outside world, outside of the mutex, allowing it to exist on multiple isolation domains, even though it shouldn’t be able to as a non-Sendable value.

This is a toy example, but considering withLock requires a sending Result return value, why is it letting me send an inout value? I would expect an error or at least a warning that I’m breaking concurrency requirements by letting the non-Sendable value exist in multiple isolation domains concurrently.

2 Likes

Definitely seems like a bug, the thread sanitizer is very unhappy with this code. Probably some top level code shenanigans.

2 Likes

I also think that's the reason. SE-0430 requires that inout sending parameter value should be in disconnected region when it's passed into the function and when the function returns. In your example the value in $0 isn't in disconnected region when function returns (it's used as both inout parameter final value and return value). It's interesting that the issue is specific to Mutex code, because compiler produce diaganostic as expected for general code like the following:

class NonSendable {}

func foo(
    _ body: (inout sending NonSendable) -> sending NonSendable
) -> sending NonSendable {
    var ns = NonSendable()
    return body(&ns)
}

func test() {
    foo { ns in 
        ns = NonSendable()
        return ns // error: returning 'inout sending' parameter 'ns' risks concurrent access as caller assumes 'ns' and result can be sent to different isolation domains
    }
} 

Mutex puts value in a wrapper struct _Cell. Not sure if it's that usage that causes the issue (though I can't reproduce it by wrapping NonSendable in a regular structure).

1 Like

So I just took a look. There are two issues here:

  1. We shouldn't let the lock take in ns. Mutex takes a sending parameter... it shouldn't be allowed to be initialized with a global with main actor isolation.
  2. There is a second issue where we are properly erroring on returning an inout sending parameter into a sending result. I actually just recently did a round of work fixing a bunch of issues with inout sending (including returning inout sending), but I missed the case of a generic inout sending being returned as a generic sending result. E.x.:
class NS {}

func test1<T>(_ x: inout sending T) -> sending T { x }

func test2(_ x: inout sending NS) -> sending NS { x }

func test3<T>(_ x: inout sending T) -> T { x }

func test4(_ x: inout sending NS) -> NS { x }

Result:

test5.swift:6:53: error: 'inout sending' parameter 'x' cannot be returned
 4 | func test1<T>(_ x: inout sending T) -> sending T { x }
 5 | 
 6 | func test2(_ x: inout sending NS) -> sending NS { x }
   |                                                     |- error: 'inout sending' parameter 'x' cannot be returned
   |                                                     `- note: returning 'inout sending' parameter 'x' risks concurrent access as caller assumes 'x' and result can be sent to different isolation domains
 7 | 
 8 | func test3<T>(_ x: inout sending T) -> T { x }

test5.swift:8:44: error: 'inout sending' parameter 'x' cannot be returned
 6 | func test2(_ x: inout sending NS) -> sending NS { x }
 7 | 
 8 | func test3<T>(_ x: inout sending T) -> T { x }
   |                                            |- error: 'inout sending' parameter 'x' cannot be returned
   |                                            `- note: returning 'inout sending' parameter 'x' risks concurrent access as caller assumes 'x' and result can be sent to different isolation domains
 9 | 
10 | func test4(_ x: inout sending NS) -> NS { x }

test5.swift:10:45: error: 'inout sending' parameter 'x' cannot be returned
 8 | func test3<T>(_ x: inout sending T) -> T { x }
 9 | 
10 | func test4(_ x: inout sending NS) -> NS { x }
   |                                             |- error: 'inout sending' parameter 'x' cannot be returned
   |                                             `- note: returning 'inout sending' parameter 'x' risks concurrent access as caller assumes 'x' and result can be sent to different isolation domains
11 |
4 Likes

Looks like there’s an existing bug for this already: `Mutex` allows to return the protected `Value` instance without reinitialization · Issue #81274 · swiftlang/swift · GitHub . Looks like a regression introduced in Swift 6.1 and still present in 6.2.

1 Like