[Pitch] Safely sending non-`Sendable` values across isolation domains

The operations on the iterator are async and not isolated, so they run on a non-actor executor. Obviously this isn't desired in this situation, but the compiler is correctly diagnosing a problem under the annotations that it has. The fix there is to have a way for async operations to inherit the isolation of their caller.

3 Likes

So there's some syntactic sugar at play here, but basically the issue, I believe, is that the next method of the iterator that gets extracted from stream is an isolation-crossing call, so by default the semantics I outlined above would cause stream to get transferred by the first such call - rendering the loop invalid.

The semantics are this way because I targeted the isolation-crossing case of actor methods for this implementation. For actor methods, it is indeed the case that all non-sendable arguments must be transferred by the call because the actor could escape them into its storage. For other isolation-crossing calls, such as the calls here, this transferring is not necessary - simply merging the regions of all non-sendable arguments and placing the result in that region is sufficient. This is an easy extension of the semantics to make, but I have not done so yet. With that extension, the above code would typecheck (modulo handling for the syntactic sugar around iterating).

I love the direction of this proposal. I have a few comments and questions that I don't think have been touched on yet.

The last paragraph of the "regions" section casts a bit more share on linear types than I think makes sense at this point. I think this discussion belongs in Alternatives Considered, in the stub section on "Relying on move-only types".

The "At a high level" section defines the rule for the region of a value in terms of an "initializer". The term is somewhat overloaded in Swift, because it refers to an initializer for a type (init) and is also sometimes used to refer to the expression that is originally assigned to a value. The two bullets also overlap a bit... what about referring to this in terms of "calls" and expressing it as, e.g.,:

  • When calling a function, the regions of all of its non-sendable arguments will be merged together so they occupy a single, shared region.
  • A non-sendable result value of a call will be treated as being within the region formed by merging the non-sendable arguments of the call. If there are no non-sendable arguments to the call, the result value is in a fresh region.

?

In various places there are references to compiler internals (non_sendable_call_argument, ApplyExpr, etc.). We should find ways to say those that don't require knowledge of the compiler.

I think the future work section on "Global actors" will need to be pulled into the proposal proper, because the analysis isn't safe if we don't consider that values in (e.g.) a @MainActor function can be assigned to a @MainActor global variable. Fortunately, I think it's straightforward.

I think the Alternatives Considered only needs to cover noncopyable/move-only/strictly-linear types.

My impression is that this analysis can be trivially extended to local variables captured by reference by (e.g.) giving each its own region. For example, this is currently rejected:

func f() {
  var x = 10
  Task.detached {
    x += 1      // error: by-reference mutable capture in concurrency code
  }

  // x is not used
}

If x (the variable, not its value) had its own region, we could defer that error and only emit it if x actually is used after the formation of the closure.

Doug

9 Likes

If a variable passed to a Task is accessed after it's passed,
it will cause problems due to simultaneous access.
However, if the variable is accessed after Task.value has been await-ed, as in the code below,
I believe there would be no problem as there's no risk of simultaneous access.
Am I correct?

// not Sendable
class NS {}

func main() async {
    var x = NS()
    let task = Task<Void, Never> { () async -> Void in
        print("use x in task: \(x))")
    }
    await task.value
    print("use x outside task: \(x)")
}

Would the proposed analysis method be able to handle such a pattern?

In my thoughts, the analyzer would require information that the closure passed to Task.init has already ended after task.value.
However, this information cannot be obtained from just the method signature, so it seems hard.

In fact, I have encountered a similar pattern with NIO's EventLoop.
I believe that the following pattern with EventLoop, similar to the Task pattern above, is also safe.

func main(eventLoop: any EventLoop) async {
    var x = NS()

    let promise = eventLoop.makePromise(of: Void.self)
    promise.completeWithTask {
        print("use x in task: \(x)")
    }

    await promise.futureResult.get()
    print("use x outside task: \(x)")
}

Is this a correct assumption?
Unfortunately, it seems quite challenging for static analysis in this case, as it requires knowledge about EventLoop.makePromise, EventLoopPromise.completeWithTask, EventLoopPromise.futureResult, and EventLoopFuture.get.