SE-0430: `transferring` isolation regions of parameter and result values

SE-0414 does let you pass "sendable values" with non-Sendable type over an isolation boundary. If the callee's isolation domain is actor isolated, region analysis has to assume that those values were merged into the actor's region, but if the callee is just isolated to the current task (i.e. it's a nonisolated async function), then you can still access all of the argument values when the call returns:

class NonSendable {}

func taskIsolated(ns: NonSendable) async { ... }

@MainActor func crossBoundary() async {
  let ns = NonSendable()
  await taskIsolated(ns: ns) 
  print(ns) // okay, ns can't have escaped into actor-isolated state
}

In the above code example, I consider ns to be a "sendable value" - it isn't referenced by any main actor isolated state, so it's safe to cross over the boundary at the call to taskIsolated, and because taskIsolated can't have merged the value into actor-isolated state, the caller has access to ns again after the call.

There are two use cases for transferring as proposed here:

  1. A synchronous call that itself doesn't cross an isolation boundary wants to pass an argument value over an isolation boundary or stash it away into actor-isolated state in the callee. For example:
class NonSendable {}

@MainActor class MyClass {
  let ns: NonSendable
  nonisolated init(ns: transferring NonSendable) {
    self.ns = ns
  }
}
  1. A call wants control over a specific argument value or the result value to indicate that it is not merged into the same region with the other arguments and results. For parameters, the callee can take advantage of the disconnected region by merging it into some other actor's region or transfer the value further. For results, the caller can take advantage of the result being in a disconnected region:
@MainActor func useOnMain(_ ns: NonSendable) { ... }

func transferAway(_ arg1: transferring NonSendable, arg2: NonSendable) async {
  await useOnMain(arg1) // okay
  await useOnMain(arg2) // error
}

For calls to transferAway above, it would be an error to pass two argument values that are already assumed to be in the same region.

2 Likes

You can't overload on transferring as a direct parameter modifier, because overload resolution happens much earlier than region analysis, so calls to such a function would always be ambiguous:

class NonSendable {}

func overload(_: transferring NonSendable) { ... }
func overload(_: NonSendable) { ... }

overload(NonSendable()) // ambiguous

You can overload on parameter or result types that include transferring function types, though, e.g.

struct GenericType<T> {}
class NonSendable {}

func overload(_: GenericType<(NonSendable) -> Void>) { ... }
func overload(_: GenericType<(transferring NonSendable) -> Void>) { ... }

We can probably suppress transferring in the mangling when used directly on parameter and result types of function declarations, which would also allow adopting transferring in existing resilient APIs, like CheckedContinuation.resume(returning:), without resorting to silgen_name for ABI compatibility.

We've deliberately inverted the SE-0414 diagnostics to emit the error at the point of concurrency and emit notes at the points of potential concurrent access (and eventually the points where two values are assumed to reference each other due to region merging). This isn't new with this proposal implementation, so if we want to continue discussing this, I suggest we do so in a separate thread.

4 Likes

So if one wants to make an overload, is it possible as a workaround to rewrite this:

func overload(_: transferring NonSendable) { ... }
func overload(_: NonSendable) { ... }

overload(NonSendable()) // ambiguous

to this:

func overload(_ argGetter: () -> transferring NonSendable) { 
  let transferringInstance = argGetter()
  ...
}
func overload(_ argGetter: () -> NonSendable) { ... }

overload() { NonSendable() } // ok

?

Is it possible to return a tuple where parameters have different transferring?

func getNonSendable() -> (transferring NonSendable_a, NonSendable_b, transferring NonSendable_c) {
  ...
}

It depends on the type of logger and the isolation of print. If the type of logger is Sendable and print is not isolated, then v remains in a disconnected region by itself, and it's okay to return v as the value for a transferring result type.

Same here, if logger is Sendable and print is (implicitly or explicitly) nonisolated, the this code is valid. The same is true for your last example.

No, overload { NonSendable() } would still require region isolation to disambiguate between the two overloads you wrote. If you specified the type of the closure explicitly, then the call would not be ambiguous.

No, transferring is only valid in nested positions in function types. You cannot write transferring structurally in tuple elements like this.

3 Likes

I haven't seen this mentioned before as a naming conflict, but Apple platforms already have a Transferable protocol — used by drag and drop, copy and paste, etc.

4 Likes

Hi everyone,

The proposal has been returned for revision to select a keyword that more closely aligns with concurrency terminology. It is otherwise quite satisfactory, and the general design has been accepted in principle. See the announcement thread for more details.

4 Likes