So, I was looking at Swift 6's new Mutex
type, and I saw that its withLock
has a new Swift6-y signature. It has a few extra bits and bobs, but here's a simplified version:
func withLock<R>(
_ body: (inout sending State) -> sending R
) -> sending R
This makes sense to me: by my understanding of sending
, ownership of State
is temporarily given up by the mutex, passed "in" to my closure, where I may split it into two pieces, so long as I can prove they're in different isolation zones. The first piece is passed back "out" of the closure argument to the mutex, where it becomes the new protected State
; the other part is returned by my closure and then by withLock
to be used by the caller.
I thought, since I can't require iOS 18 just yet, why not update my mutex type to use sending
in this way, rather than require full Sendability, as I had to under Swift 5?
But I quickly ran into problems. To illustrate, here's a simple not-a-mutex type with a copycat API:
class NotAMutex<State> {
var state: State
init(state: sending State) {
self.state = state
}
func withNoLock<R>(
_ body: (inout sending State) -> sending R
) -> sending R {
body(&state)
// |- error: sending 'self.state' risks causing data races
// | `- note: task-isolated 'self.state' is
// passed as a 'sending' parameter; Uses in callee may race
// with later task-isolated uses
}
}
I'm already struggling to interpret this. My class isn't Sendable, so nobody else can be interfering with state
. inout sending
here is supposed to guarantee that there are no later "uses in callee" that can race with later task-isolated uses.
how can I call a function with an inout sending
parameter?
Leaving that aside for now, let's try to use this API:
// a dumb non-sendable linked list for demo purposes
class NotSendable {
var next: NotSendable?
}
@available(*, unavailable)
extension NotSendable: Sendable {}
func test() {
let noMutex = NotAMutex(state: NotSendable())
let next = NotSendable()
noMutex.withNoLock {
$0.next = next
}
// |- error: 'inout sending' parameter '$0' cannot be task-isolated
// at end of function
// | `- note: task-isolated '$0' risks causing races in between
// task-isolated uses and caller uses since caller assumes
// value is not actor isolated
let value = noMutex.withNoLock {
$0.next.take()
}
// |- error: 'inout sending' parameter '$0' cannot be task-isolated
// at end of function
// | `- note: task-isolated '$0' risks causing races in between
// task-isolated uses and caller uses since caller assumes
// value is not actor isolated
}
So it seems I can neither transfer a non-Sendable value into the mutex, nor transfer one out.
I think in is a problem because Swift can't tell that withLock
doesn't call its closure twice, which would result in aliasing my NotSendable
instance. That makes some sense logically, but begs the question:
how can I transfer a value into an inout sending
parameter?
But maybe "out" just doesn't know that "take" is actually removing the value from the inout sending State
— let's see if we can fix that:
extension Optional where Wrapped: ~Copyable {
mutating func take2() -> sending Optional {
switch consume self {
case .none:
self = nil
return nil
case let .some(wrapped):
// |- error: returning task-isolated 'wrapped' as
// a 'sending' result risks causing data races
// `- note: returning task-isolated 'wrapped'
// risks causing data races since the caller
// assumes that 'wrapped' can be safely sent
// to other isolation domains
self = nil
return .some(wrapped)
}
}
}
So I can't consume self
and end up with an isolated value — not 100% sure, but maybe because self
could be part of a larger isolation region which aliases wrapped
. But I can't see a way to mark self
as sending
? Anyway, it suggests a less intuitive API might work:
extension Optional where Wrapped: ~Copyable {
// uh-oh, inout-sending-ception
static func take(_ self: inout sending Optional) -> sending Optional {
switch consume self {
case .none:
self = nil
return nil
case let .some(wrapped):
self = nil
return .some(wrapped)
}
}
}
This does actually compile! Still, even if this works for Optional
, where it's simple for the compiler to prove that I've correctly severed wrapped
from its surroundings, I don't see how this cursed knowledge extends to eg. Array.removeLast()
, where the elements might have interdependencies between them — it seems like once an element has been added to an array, it could never be severed from its brethren again?
in general, how does one go about splitting up isolation regions once they've been formed?
Anyway, let's go back to trying to use it:
let value = noMutex.withNoLock {
Optional.take(&$0.next)
// |- error: sending '$0.next' risks causing data races
// `- note: '$0.next' used after being passed as a
// 'sending' parameter; Later uses could race
}
// `- note: 'inout sending' parameter must be reinitialized before
// function exit with a non-actor isolated value
Back to having nearly no idea what it's talking about, and even less idea what to do to fix these. AFAICT, the inout parameter is reinitialized, by the "out"?
how can I transfer a value out of an inout sending
parameter?