Sendability of Async Closures

SE-0461 clarifies that (non-sending, non-@Sendable) async closures inherit the actor isolation where they’re declared:

If the contextual type of the closure is neither @Sendable nor sending, the inferred isolation of the closure is the same as the enclosing context

If the isolation is held by the closure value itself, why can’t that closure be sent to another isolation domain?

func sendToNonIsolated(_ operation: @escaping () async -> Void) {
  // Crosses isolation boundary
  Task.detached {
    await operation() // ERROR
  }
}

I understand the closure is not marked as @Sendable, but if it were sent and called on another isolation unsafely, the closure still holds onto its isolation and is called on that original isolation, not the isolation where it’s being called:

func sendToNonIsolated(_ operation: UncheckedClosure) {
  Task.detached {
    // This task is running in a nonisolated context, but operation.value
    // is still isolated to the TestActor.
    await operation.value()
  }
}

struct UncheckedClosure: @unchecked Sendable {
	let value: () async -> Void
}

final actor TestActor {
  var count: Int = 0
  func send() {
    let unchecked = UncheckedClosure {
        self.count += 1 // Ensures this is not a `@Sendable` closure
		print(#isolation) // Prints TestActor
	}
	sendToNonIsolated(unchecked)
  }
}

Even though the closure was sent to another isolation domain using @unchecked Sendable, it’s still being called on its original isolation, implying the await to call it is actually performing an extra actor hop to get back onto the correct isolation before running the closure in that isolation.

If an async closure inherits its actor context, and takes the necessary steps to ensure it’s called on that actor, even when forcefully sent to another isolation context, why are any async closures non-@Sendable? It seems like no matter which isolation context it’s called in, it will always wait for the original isolation to be available and run on that isolation, which makes it safe to call from any isolation since the closure is implicitly isolated. Are there other cases I’m not considering?

I understand I can just mark the closure as @MainActor or use some other global actor to ensure its isolation information is held onto with its type information, but for writing an API where I don’t know the intended isolation, why can’t I take in a regular async closure (non-sending, non-@Sendable) and know that it can be called from any isolation?

if the closure is not actor-isolated, i.e. it inherited no actor isolation from its enclosing context. consider the following:

nonisolated func noIsolation() async -> Void {
  var taskIsolatedState = 0
  let notIsolated: () async -> Void = {
    taskIsolatedState += 1
    await Task.yield()
    taskIsolatedState += 1
    print("not isolated: \(taskIsolatedState)")
  }

  // called in another (concurrent) Task
  sendToNonIsolated(notIsolated)

  // called in original Task
  await notIsolated()
}

if your example function could compile as-is, then it would allow the introduction of a potential data race as the closure-captured state could be accessed from both the original Task in which it was created, and the detached Task the closure parameter is passed to. the diagnostics have to be fairly conservative to prevent situations like this, and so that means that in cases in which the type system cannot currently provide sufficient information to (statically) prove that there cannot be a race, the diagnostics try to err on the side of caution and prevent the behavior.

the type system does have a mechanism to express 'some unknown isolation', and that is @isolated(any). however, that annotation could still represent a nil actor, so again there are still limitations with how such parameters can be used. e.g. if you were to update your example function signature to:

func sendToNonIsolated(_ operation: @escaping @isolated(any) () async -> Void) { ... }

you'll get the same error because it's still not statically known within that function's implementation that the isolation is non-nil. currently, without a sending or @Sendable attribute somewhere, i think it will be difficult to avoid this sort of diagnostic cropping up.

i think there may be some improvements that could potentially be made to tracking isolation and suppressing some of these isolation crossing errors in cases in which it can be determined that the closure invocations must be serialized.

Just want to point out that Task.detached(name:priority:operation:) in your example takes a sending closure, so the text in SE-0461 you quoted doesn't apply to it.

This is more a question of why doesn’t it apply. If I forcefully send it via @unchecked Sendable (which invalidates all guarantees in concurrency), the closure still retains its isolation even though nothing in the type (() async -> Void) indicates that it would the way a @MainActor () async -> Void closure would, so there doesn’t appear to be a reason that () async -> Void can’t be sent since it’s guaranteed to be called on the same isolation it was declared in. Except as @jamieQ pointed out: when the closure is nonisolated.

In addition to non-isolated closures, I believe closures declared in non-global actors are non-sendable too if they capture actor states by reference. IMO Sendable was a simple concept when it was defined in SE-0302. The requirments of a Sendable closure in that document didn't depend on the closure's isolation. It was later enhanced in SE-0434 for global actors. Your suggestion (if a closure's isolation is known it should be sendable) goes further. While it would be powerful, I doubt that it's feasible.

PS: below is a minor change to Jamie's code. It doesn't compile, although there is no data race. If this isn't a bug, I think it somehow aligns with what I said.

  nonisolated func noIsolation() async -> Void {
    var taskIsolatedState = 0
-   let notIsolated: () async -> Void = {
+   let notIsolated: nonisolated(nonsending) () async -> Void = {
      taskIsolatedState += 1
      await Task.yield()
      taskIsolatedState += 1
      print("not isolated: \(taskIsolatedState)")
    } 
 
    // called in another (concurrent) Task
    Task {
      await notIsolated()
    } 
 
    // called in original Task
    await notIsolated()
  }