Why is it not OK to pass a task-isolated value inside a `nonisolated(nonsending)` closure to a `@concurrent` function?

Does anyone know why this code doesn't compile?

class NS {
	func modify() {}
	@concurrent func modifyAsync() async {}
}

actor A {
	func run(_ fn: nonisolated(nonsending) () async -> Void) async {
		await fn()
	}
}

nonisolated func test() async {
	let a = A()
    let ns = NS()
	await a.run() {
		ns.modify() // OK
        await ns.modifyAsync() // Not OK
	}
}

The diagnostic:

note: sending nonisolated(nonsending) task-isolated 'ns' to @concurrent instance method 'modifyAsync()' risks causing data races between @concurrent and nonisolated(nonsending) task-isolated uses

It's OK to transfer a task-isolated value in a regular closure to global executors (as long as it isn't a sending parameter). So I wonder why a task-isolated value in a nonisolated(nonsending) closure isn't allowed? Note the closure neither take parameters nor return values, so the value doesn't interact with caller's isolation.

Below are my test results of all four combinations of nonisolated(nonsending) and sending in the closure's type. It appears that it's nonisolated(nonsending) that causes the issue and having sending or not have no influence.

has sending? has nonisolated(nonsending)? ns.modifyAsync() call
no no OK
yes no OK
no yes Not OK
yes yes Not OK

Note: a disconnected value in a nonisolated(nonsending) closure doesn't have this issue.


While thinking about this, I realized another related question. In which scenario a nonisolated(nonsending) closure is neither Sendable nor sending? IMO if a nonisolated(nonsending) closure is defined in a different isolation than its caller (I think this is typical because otherwise it would unnecessary to be nonisolated(nonsending)), then it must be either Sendable or sending. Am I missing something?

2 Likes

I don't know the answer to this question. I've been playing around with it a bit, and I cannot come up with a scenario where this situation would be unsafe but the version without nonsending would be ok. It is possible that this is simply an RBI limitation/bug, but also, these things aren't always obvious so that's not a certainty.

Yeah that's a good question. What occurs to me is that, when nonisolated(nonsending) is set as a default, these will look like regular old function types. And in that case, it seems like it would be common for them to be both defined and then used with the same isolation.

It compiles if you explicitly add ns to the capture list

When ns is captured in the run method’s closure, it is transformed from a disconnected value into a task-isolated value (more precisely, a nonisolated(nonsending) task-isolated value). Later on, modifyAsync attempts to send the task-isolated self to the @concurrent "region", crossing an isolation boundary.

But the value still has not left the current task? And I don't see how it could, because the closures are non-escaping.

ns is no longer statically guaranteed to execute on the same executor as use(_:):

class NS {
  @concurrent func modify() async {}
}

// May be called from the MainActor
nonisolated(nonsending) func use(_ ns: NS) async {
  // Will execute on some `TaskExecutor`, 
  // "guaranteed" to be different from the MainActor
  await ns.modifyAsync()
}
1 Like

That makes sense. I didn't think of it. That explains why nonisolated(nonsending) doesn't imply sending.

Did you mean change like this? I can't reproduce it.

  nonisolated func test() async {
      let a = A()
      let ns = NS()
-     await a.run() {
+     await a.run() { [ns] in
          ns.modify() // OK
          await ns.modifyAsync() // Not OK
      }
  }

I have the same opinion as @mattie. By definition a task-local isolated value can be transferred to @concurrent function. Its just that it can't be transferred to another actor isolation. (I'm sure you were aware of this).

I also noticed that passing parameter to a nonisolated(nonsending) function produces the same diagnostic, which I believe is legitimate. However, I think the diagnostic of nonisolated(nonsending) closure is bug. Although a captured value in closure has similar behaviors as function parameter in many cases, they are very different in this case.

  • A parameter passed to a nonisolated(nonsending) function is in caller's isolation
  • A parameter captured by a nonisolated(nonsending) closure is in the same isolation where the closure is defined. Typically that's not the same as caller's isolation.

That's why IMO they shouldn't have the same behavior. I suspect it's very likely the author of the related code omitted the difference and used the same logic in both cases. I'll file a bug soon.

1 Like

Yes, but task-isolated and nonisolated(nonsending) task-isolated are not the same.

class NS {
  @concurrent func modify() async {}
}

// same isolation region
@concurrent func f(_ ns: NS) async {
  await ns.modify()
}

// statically different isolation region
nonisolated(nonsending) func ff(_ ns: NS) async {
  await ns.modify()
}

actor A {
	func run(_ fn: nonisolated(nonsending) () async -> Void) async {
		await fn()
	}
}

nonisolated func test() async {
	let a = A()
    let ns = NS()
	await a.run() { // runs on `a`
		ns.modify()
        await ns.modifyAsync() // runs statically not on `a` 
	}
}

In this case, the parameter migth be in actor isolated region before it's passed to the function (it might be in disconnected region too, but we need to consider the strictest situation). That's why it shoudn't be transferred anywhere.

In this case, ns is defined in test() function, not in actor a. Also it's in disconnected region. That's why it can be sending(note the closure is nonsiolated and its isolation is inferred as sending). Ideally it would be disconnected in the closure too, but it's hardcoded task-isolated in the current implementation. I can't see any reason why it can't be passed to a @concurrent function. IMO the fact that the closure runs on actor a has no influence on it.

The key difference between the two cases:

  • In the first case, function parameter might be actor isolated so it can't be transferred anywhere.
  • In the second case, capture parameter is owned by the closure and doesn't interact with the closure's caller's isolation in any way, so the closure being nonisolated(nonsending) shouldn't influence ns's behavior.