Sending, inout sending, Mutex

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).

12 Likes