I've run into a situation where I need to "capture" a non-Sendable self in a Sendable closure, but really only so it can then later be used in a way that certainly seems safe. However, it does not work and I'm having trouble deciding if this is a bug, a current limitation, or could never work at all.
import Dispatch
func backgroundWork() -> Int { 42 }
class NonSendable {
func passthroughIsolation(isolation: isolated (any Actor)) {
DispatchQueue.global().async {
let result = backgroundWork()
Task {
// yes yes but self is only ever touched on `isolation`
_ = isolation
// error: capture of 'self' with non-sendable type 'NonSendable' in a `@Sendable` closure
self.processResult(result)
}
}
}
func processResult(_ value: Int) {
}
}
Does anyone have any ideas? Or perhaps there is an alternate pattern I could use without making the containing function async?
my intuition here is that something structured like this is not going to be possible, at least not without more sophisticated data flow analysis. as things stand today i don't think you can capture non-Sendable values within Sendable closures.
one possibility to achieve the desired effect may be to split apart the work in the different isolation domains (rather than nesting it), and coordinate between them with an appropriate communication device. here's an variant that sets up the distinctly-isolated work as 'siblings', which keeps the non-Sendable value from crossing the Sendable closure boundary. the dependency between the work is managed with an AsyncStream (maybe there's a better tool in async-algorithms):
import Dispatch
func backgroundWork() -> Int { 42 }
class NonSendable {
func passthroughIsolation(isolation: isolated (any Actor)) {
let (stream, continuation) = AsyncStream<Int>.makeStream()
Task {
_ = isolation
for await result in stream {
self.processResult(result)
break
}
}
DispatchQueue.global().async {
let result = backgroundWork()
continuation.yield(result)
continuation.finish()
}
}
func processResult(_ value: Int) {
}
}
edit: and upon further reflection, if the use of Dispatch isn't instrumental in this example, breaking out the background work into its own Task could eliminate the need for a new 'communication channel' type, as something like the following should also suffice (and IMO is structured more 'naturally'):
func passthroughIsolation(isolation: isolated (any Actor)) {
let bgWork = Task.detached {
return backgroundWork()
}
Task {
_ = isolation
let result = await bgWork.value
self.processResult(result)
}
}
You're right, I think handling this would require enhancements to RBI, and those enhancements are no doubt non-trivial at best. But I still believe!
In the mean time what you've come up with here is really clever. It doesn't feel excellent that a loop is required to wait for a single value. But it's not so bad and really does solve the problem. Thanks so much!
Yeah, moving the background work into its own async function was what occurred to me too. If it’s not important that the work literally happen on a global GCD queue then you can also do away with the whole continuation dance and just call the function directly. Since (currently) nonisolated async functions are scheduled on the global concurrent executor.
Ok you are all embarrassing me now. This is way better. Thanks so much @Nickolas_Pohilets !
And, absolutely @Jumhyn, that's exactly what I'm going to do as well.
But I do still think the general problem and the interaction with the isolation checking is interesting, and would love to know if this is something the compiler should/could do a better job with.