Sending, inout sending, Mutex

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?

2 Likes

Mutex is implemented in a way that sidesteps this entire problem. Your example is not really well defined because your pass an inout reference to an ivar in a class which comes with its own exclusivity checking etc. This may be the reason for this particular error.

This code actually compiles fine for me using Mutex and a nightly. I'm sure something has changed on the compiler side to allow this cc @Michael_Gottesman

This is the more tricky part. Yeah, we don't have a way to annotate a closure as being ran only once, but the compiler has to be very conservative about how it treats captures. It becomes more apparent to the compiler when next is some noncopyable value and the closure says it only runs once because then the compiler knows that the value must not be used after it was consumed in the closure.

3 Likes

My actual code has the value stored in a ManagedBuffer with an os_allocated_unfair_lock_t/pthread_mutex_t in the header. Was still getting warnings passing &elementPointer.pointee to the closure though, which is what Mutex does...

Ah, this actually gave me a hint; making my generic param State: ~Copyable actually improved things, I get fewer warnings now.

You can't put it in the header either because that's still a class ivar. It has to be an element of the ManagedBuffer.

To clarify in case I've miscommunicated:

  • I use a ManagedBuffer<os_allocated_unfair_lock_t, State>
  • so the lock is in the header, the protected value is in the elements
  • I don't get warnings from the lock itself, only from the state

But I spoke too soon earlier, I am getting a warning on this code, which AFAICT is exactly the same as Mutex:

    private static func withPlatformLock<R: ~Copyable, E: Error>(
        _ lockPointer: UnsafeMutablePointer<os_allocated_unfair_lock_t>,
        _ statePointer: UnsafeMutablePointer<State>,
        _ body: (inout sending State) throws(E) -> sending R
    ) throws(E) -> R {
        os_unfair_lock_lock(lockPointer)
        defer {
            os_unfair_lock_unlock(lockPointer)
        }
        return try body(&statePointer.pointee)
//                       | warning: sending 'statePointer.pointee'
//                         risks causing data races; this is an error
//                         in the Swift 6 language mode
//             `- note: task-isolated 'statePointer.pointee'
//                is passed as a 'sending' parameter; Uses in
//                callee may race with later task-isolated uses

    }

(I have other problems using ManagedBuffer.withUnsafeMutablePointers too, so even if I could solve this, I'm still not sure I can get it to work)

I think explaining it in this manner is misleading since in assumes there is
dataflow in between the value passed into body and the result when the language
defines this in a separable manner at the function entry/exit. The rules are:

  • The inout sending parameter of body has the requirement that on entry to the
    function it is disconnected (i.e. not actor isolated) and is not in the same
    region as any other parameters (inout or otherwise). On exit from body, whatever
    value is in the inout sending parameter must again be disconnected and in a
    separate region from any other parameters (inout or otherwise) or any return
    values. Importantly each of these connections are separate logical conditions
    that are not evaluated without relation to each other. So for instance, it allows for one to write code like the following:
func test(_ x: inout sending State, _ y: inout sending State, z: State) {
  x = y
  //... do stuff ...
  x = z
  //... do more stuff ...
  // reassign x to state so that x is in different regions from z on return.
  x = State()
}
  1. The return of R is saying that the value returned from body is guaranteed to
    be disconnected on return and in its own region separate from all
    parameters. This is an important property since it guarantees that the caller of
    test can assume that the return value is in a different region from the
    parameters of test. It does not say anything further than that about the body of the function.

The result of this implication is that one cannot return a part of body's inout
parameter from body without reassigning the parameter since on return the inout parameter
and result will be in the same region.

That being said, there are some known issues around returning inout sending
parameters and aliasing of inout parameters where we are allowing it to
happen. That is an implementation bug though, not a part of the model.

A task isolated region is a region that is conservatively connected to some sort
of state in the current task. So if one were to try to send such a value, one
would be introducing potentially a race in between the current task and
where-ever one is sending the value to.

In this example, self and everything that it points to is considered to be apart
of the same region. So when one sends a part of self, one has also sent self
since self could still refer to state. As an example of how this can be problem, consider
the following swift code that is a modified version of your example that displays a race:

import Darwin

class CounterWrapper {
  var counter: Int = 0
}

class MyState {
  var counterWrapper: CounterWrapper

  init(_ x: CounterWrapper) { counterWrapper = x }
  init() { counterWrapper = CounterWrapper() }
}

class NotAMutex {
  var state: MyState
  var state2: MyState

  init() {
    // Create an object graph that looks as follows:
    //
    // self ----> self.state  ----> self.state.counterWrapper
    //        \-> self.state2 --/
    //
    // This creates a region of (self, self.state, self.state2,
    // self.state.counterWrapper)
    self.state = MyState(CounterWrapper())
    self.state2 = MyState(self.state.counterWrapper)
  }

  func withNoLock(
    _ body: (inout sending MyState) -> Void
  ) {
    // Needed to tell the compiler to avoid the error...
    // since we are going to race on state.counterWrapper, we can
    // pass it in as its own var.
    nonisolated(unsafe) var x = state
    body(&x)
  }

  func withNoLock2(
    _ body: (inout sending MyState) -> Void
  ) {
    // Needed to tell the compiler to avoid the error...
    // since we are going to race on state2.counterWrapper, we can
    // pass it in as its own var.
    nonisolated(unsafe) var x = state2
    body(&x)
  }
}

func incrementOnBackgroundThread(_ y: sending MyState) {
  Task {
    let x = y.counterWrapper.counter

    // Simulates an evil scheduler that preempts incrementOnBackgroundThread at
    // this point.
    sleep(1)

    y.counterWrapper.counter = x + 1
    print("T1: \(x), \(y.counterWrapper.counter)")
  }
}

func incrementOnBackgroundThread2(_ y: sending MyState) {
  Task {
    y.counterWrapper.counter += 1
    print("T2: \(y.counterWrapper.counter)")
  }
}

func test() {
  let x = NotAMutex()
  x.withNoLock { (value: inout sending MyState) -> Void in
    incrementOnBackgroundThread(value)
    x.withNoLock2 { (value: inout sending MyState) -> Void in
      incrementOnBackgroundThread2(value)
      value = MyState()
    }
    value = MyState()
  }
  sleep(3)
}

test()

By using sleep, we are able to simulate an "evil scheduler" that preempts the
call to incrementOnBackgroundThread() and thus create a race against the value
(I loaded x before just to simulate a situation where we load the value, get
pre-empted, add the stale value, and then lose an add).

Notice how we are still able to refer to x within withNoLock (since x is
guaranteed to be live within withNoLock) and thus can access state within the
region of x via x.withNoLock2.

I think if you think about inout sending as a function boundary guarantee it would
elucidate these examples:

  1. The first example, you are capturing next into a closure. The closure does
    not know how many times it will be called... so next is viewed as task isolated
    since state outside of the closure within the current task could still have a
    reference to next. By the guarantees of inout sending, we need the inout sending
    parameter to be disconnected on return so that we can safely send noMutex when
    we return.

  2. In the second example, one is escaping part of $0's region without
    reassigning $0 so that on function return it is still disconnected. There is a
    current bug that Alejandro ran into that allows for one to return an inout
    sending parameter without reassigning. This is a bug. Strictly from a region
    isolation perspective since the signature of Optional.take is mutating func take() -> Optional<Wrapped>. The rules of region isolation imply that the result is considered to be in the same region as self... so one would not be
    able to return it. If the result was sending this would not be an issue and
    arguably it /should be/. One can work around this by assigning over $0
    explicitly.

As per my response above... this is correct but the wrong way to think about
it. Instead, it is that mutating functions merge the regions of self with all
results unless the result is sending. The reasoning is local to the
caller... and does not consider anything in the callee.

This compiles for me with ToT. Since you have assigned over self with nil, you
are fine. I think your intuition though is correct... Optional.take should
return its value as sending so that the information is communicated
appropriately to the caller of Optional.take.

This is mentioned in SE-0430 as an extension. See: swift-evolution/proposals/0430-transferring-parameters-and-results.md at main · swiftlang/swift-evolution · GitHub. The name was just a straw person name... so don't take that too seriously.

What is happening here is that $0 has to be disconnected on return. So you need
to reassign over $0 to reset its region.

One interesting wrinkle here is that one may assume that one could just assign
over $0.next... this does not follow since from the perspective of region
isolation, $0 is still in the same region as the return value. As an example:

class Node {
  var next: Node? = nil
}

func sendValue(_ x: sending Node) {}

func test() {
  var x = Node()
  let y = x.next!
  x = Node()
  sendValue(y)
  sendValue(x.next!)
}

func test2() {
  var x = Node()
  let y = x.next!
  x.next = nil
  sendValue(y)
  sendValue(x.next!)
}

which gives me:

% ./bin/swift-frontend test6.swift -c -swift-version 6     
test6.swift:17:7: warning: variable 'x' was never mutated; consider changing to 'let' constant
15 | 
16 | func test2() {
17 |   var x = Node()
   |       `- warning: variable 'x' was never mutated; consider changing to 'let' constant
18 |   let y = x.next!
19 |   x.next = nil

test6.swift:20:3: error: sending 'y' risks causing data races
18 |   let y = x.next!
19 |   x.next = nil
20 |   sendValue(y)
   |   |- error: sending 'y' risks causing data races
   |   `- note: 'y' used after being passed as a 'sending' parameter; Later uses could race
21 |   sendValue(x.next!)
   |               `- note: access can happen concurrently
22 | }
23 | 

One needs to reassign over the inout parameter so that the inout parameter is in
a separate region from the value that was derived from it and returned. (I realize I said this a few times above... sorry for being a bit of a broken record... but I wanted to respond directly here to make it easier for people scanning).

11 Likes

Thank you so much for this detailed reply! There is a lot going on here, I will spend some time studying it and see what I can learn :sweat_smile:

Playing around with this, I think there really is a hole overzealous restriction in inout sending. Even in this variation

    func processAndSave<R>(
        _ input: sending State,
        _ body: (inout sending State) -> sending R
    ) -> sending R {
        var state = input
        let result = body(&state)
        self.state = consume state
        return result
    }

the diagnostics say

        let result = body(&state)
   |                      |- error: sending 'state' risks causing data races
   |                      `- note: 'state' used after being passed as a 'sending' parameter; Later uses could race

But this is incorrect; if this were true, the body would not be able to "send back" the value in the inout. An inout parameter should formally be treated as a consume followed by a reinitialization, and therefore an inout sending parameter should be treated as sending-in, sending-out.

EDIT: Reduced: Compiler Explorer

(As an aside, the "transfer into", "can't prove the closure is only called once" case is well-known in Rust, and the answer is take-and-unwrap. Unfortunately, ! also isn't consuming (yet?).)

3 Likes

I wanted to revisit this — based on what you said elsewhere in your answer, I'm now thinking that the compiler has to assume that self and therefore wrapped are part of some larger isolation region, and therefore that sending wrapped out is indeed not possible (and therefore that the Swift release in Xcode 16 is correct, and that a bug has been introduced since then).

Which makes me think that the correct formulation would be something like this:

extension Optional where Wrapped: ~Copyable {
    mutating sending func take2() -> sending Optional {
        /* same implementation */
    }
}

Except that this doesn't compile; there doesn't seem to be a way to mark self as sending (effectively, disconnected, if I'm following!). Which is why my static version did work, because I could mark the explicit argument taking the place of self as being sending. Should there be? It seems useful!

1 Like

Well, Rust at least has FnOnce, which helps… I do feel like we're missing that in Swift now.

Yeah, I'm not sure take() can promise sending this way (EDIT: upon reread I see this is a simplified version of Michael’s CounterWrapper example):

class Example {
  var a: NotSendable?
  var b: NotSendable?

  init() {
    a = NotSendable()
    b = a
  }

  func produceDisconnectedValuePerhaps() -> sending NotSendable? {
    // imagine this was allowed
    return self.a.take()
  }
}

func process(_: NotSendable?) {}

@MainActor
func test() {
  let example = Example()
  let supposedlyIsolated = example.produceDisconnectedValuePerhaps()
  _ = Task {
    process(supposedlyIsolated)
  }
  process(example.b) // oops, this still aliases supposedlyIsolated
}

So maybe the nonisolated(unsafe) is unavoidable—you can't split a region without stepping outside of what the compiler can check, because the compiler (conservatively, correctly!) assumes that merged regions may contain cross-references. (But inout sending should also work in simpler cases.)

1 Like

So would Disconnected look something like this:

struct Disconnected<Value: ~Copyable>: ~Copyable, @unchecked Sendable {
    fileprivate nonisolated(unsafe) var value: Value

    init(value: consuming sending Value) {
        self.value = value
    }

    consuming func consume() -> sending Value {
        value
    }

    mutating func swap(_ other: consuming sending Value) -> sending Value {
        let result = self.value
        self = Disconnected(value: other)
        return result
    }

    mutating func take() -> sending Value where Value: ExpressibleByNilLiteral {
        let result = self.value
        self = Disconnected(value: nil)
        return result
    }
}

Obviously it can't be implemented safely, but having used 2 unsafe things I'm now terrified I've got something wrong :sweat_smile:

Yes you are correct. self needs to be sending to break the task isolatedness of self.

I also think that sending for self would be useful (as well as potentially for computed properties).

In terms of the bugs with inout sending/returns... I actually already have a fix for it on a branch. I just was pre-empted (reference intended) and had to fix other things first. I'll post here when it lands.

3 Likes