The following code allows a non-sendable object to be sent from one isolation to another, without any compiler warnings.
public class NonSendable {}
// This function is being called from the main actor.
// As a nonisolated(nonsending) function, it should
// therefore inherit the main-actor-ness
nonisolated(nonsending) func doStuff() async {
let ns = NonSendable()
// All good so far
MainActor.assertIsolated()
print("Yes, on the main thread")
await withTaskGroup(of: Void.self) { group in
// This assertion fails
MainActor.assertIsolated()
// So shouldn't this capture be disallowed?
print(ns)
}
}
@MainActor func run() {
// Here is where we're calling it
await doStuff()
}
I was hoping that the body of withTaskGroup would inherit the dynamic isolation of its surrounding context, but that doesn’t seem to be the case. There is an isolation: parameter, which takes #isolation as a default… but that’s the static isolation of this code, which is nonisolated. As a result, the body of withTaskGroup runs on a thread managed by the Swift concurrency runtime.
But… doesn’t that mean that ns is being transferred between isolation boundaries? Is Region-Based Isolation kicking in here? My actual code was using a Core Data managed object, which isn’t safe to transfer between isolations even if there’s no risk of concurrent access. I was hoping that the non-sendable-ness of it would call out the violation here.
So, two questions here:
Is there any way we can make the body of withTaskGroup run on the caller’s thread, like a nonsending function?
SE-414, region-based isolation, is working as intended. Transferring non-sendable values across isolation boundaries into different isolation domains is safe, as long as the compiler can prove through flow analysis that no concurrent access occurs. The most important point to understand is that, when a non-sendable value is being transferred, it is merged into and removed from isolation regions. At any given time, the isolation region containing the non-sendable value can only be accessed by one isolation domain. Otherwise, there is a risk of a data race, and we must diagnose an error.
I would recommend reading the proposal, not only is it excellent, but also because I’m terrible at explaining it :D
If you move print(ns) into an addTask closure, it is no longer possible to prove that ns won’t be accessed concurrently:
await withTaskGroup(of: Void.self) { group in
group.addTask {
print(ns) // error: passing closure as a 'sending' parameter risks causing data races
}
}
Whereas this is fine:
let ns = NonSendable()
await withTaskGroup(of: Void.self) { group in
print(ns)
}
print(ns)
A better example is this:
class NonSendable {}
actor A {
var value: NonSendable
init(_ value: NonSendable) {
self.value = value
}
}
func invalid() {
let ns = NonSendable()
let a = A(ns) // error: sending 'ns' risks causing data races
print(ns) // 'ns' can no longer be safely accessed from here since its region was merged with that of 'a'
}
func valid() { // okay, 'ns' was never accessed after its region was merged with that of 'a'
let ns = NonSendable()
print(ns)
let a = A(ns)
}
I doubt if run-once closure is relavant in this case. Below is a simplified example. Note fn argument is non-escaping. Is there a scenario in which compiler can't detect a data race with existing rules?
func doStuff2(_ fn: sending () -> Void) {
fn()
}
func test2() {
let ns = NonSendable()
doStuff2 {
doStuff2 {
print(ns) // error: closure captures 'ns' which is accessible to code in the current task
}
}
}
(And remember, the compiler is not looking at this implementation, only at the interface!) then your calling code is broken, since ns can't be sending-ed into the nested doStuff2 twice.
So yes, still a problem with once-callable functions, even though there's no concurrency.
Thanks, it makes sense indeed. IIUC the missing piece in this specific example is a mechanism to make sure a sending closure can only run once, instead of a general once-callable closure. And that seems to be very similar to how other RBI rules are implemented.
In general sending/"in its own region" and non-Copyable are very closely related concerns, and once-callable closures solve real problems in both domains.
But probably by implementation, a once-callable closure would be a non-Copyable closure where the call operator is consuming. So in this case you'd have to write something like func doStuff2(_ fn: sending consuming @Once () -> Void).
That's interesting, but I wonder why it's necessary to specify consuming and @Once explicitly? Doesn't sending imply them already? All existing rules in RBI don't depend on things like these and they work well. So, in my naive understanding, why is it not feasible to implement "sending closure should only run once" rule in a similar way (purely based on code flow analysis)? I can think of complex use case like a sending closure fn1 is captured by another closure fn2, but I think in this case a simple rule like "only fn2 is callable" should suffice.
(Let's ignore Sendable value in the discussion for simplicity)
I think transferring a sending value does imply consuming it in the original isolation (sending is actually more strict than consuming, because the latter allows implicit copy in caller). If the value is a closure, your example demonstrates why it should be once-callable. So IMO that's not a limitation but a requirement.
I think that you're right that sending is stricter than consuming, and could reasonably always imply it, though that's not how the language currently works.
I don't think you're right that a sending closure need necessarily be once-callable, though. Consider this contrived example:
(note that this may not build today since MainActor.run isn't known to be once-callable, but that's irrelevant to the thought, which is that it there's no reason it shouldn't be possible to sending a nonisolated closure to another isolation, then call it repeatedly)
A sending closure doesn't need to be once-callable, but if it's not, then it can't capture a non-sendable variable through sending.
Otherwise, the body of the closure could then use sending to cross an isolation domain, racing against future invocations of the closure.
Imagine a caller using runRepeatedlyOnMainActor like this:
let ns = NonSendable()
runRepeatedlyOnMainActor {
print("Non-sendable value \(ns) accessible!")
smuggleToNonisolated(ns)
}
Where smuggleToNonisolated is a function to "smuggle" a non-sendable value to a non-isolated domain using sending:
nonisolated
func smuggleToNonisolated(_ ns: sending NonSendable) {
// NonSendable available in a nonisolated concurrency domain...
// ...
}
Let's see what would happen when runRepeatedlyOnMainActor starts running:
(1) runRepeatedlyOnMainActor runs its closure on the Main Actor.
(2) The body of the closure prints ns from the main actor.
(3) The body of the closure sends the non-sendable ns to a non-isolated concurrency domain through testSendingNonSendable.
(4) runRepeatedlyOnMainActor runs its closure on the Main Actor.
(5) The body of the closure prints ns from the main actor.
Where (5) is using ns from the Main Actor after (3) sent it to a non-isolated concurrency domain, allowing for concurrent access of a non-sendable value (which should be diagnosed as an error!).
error: sending 'ns' risks causing data races [#SendingRisksDataRace]
note: task-isolated 'ns' is passed as a 'sending' parameter; Uses in callee may race with later task-isolated uses
This violates my revised rule (the less strict one) because, once closure is called in doSomething1, it shouldn't be transferred to a different isolation (the task closure is in @GlobalActor2 isolation), because if compiler allows it to be transferred the closure might be invoked in a different isolation.
That said, I think my original rule (the strict one) is correct, as explained by @Andropov too. I have a simpler example to demonstrate the issue if a sending closure run multiple times in the same isolation:
test() in the example currently doesn't compile because of #85231. IMO that should be allowed, which would enable many practical use cases. Instead, compiler shouldn't allow fn be called multiple times in doSomething(_:) (even if it's called in the same isolation).
EDIT: I later realized there might be an issue in the above code. modifyValueOnGlobalExecutor() doesn't work as intended. Because Int is sendable and hence the change of value within the function doesn't affect the variable whose value is passed to the function.
Yes, that's why I said let's ignore Sendable value in the discussion. If a closure doesn't capture non-sendable variables, it's Sendable. If a function needs to run it multiple times, it should take a Sendable closure, instead of a sending closure.
I know the compiler already diagnoses it (as it should!).
I was just pointing out that, while there's nothing unsafe about using a sending () async -> ClosureResult parameter multiple times within the body of a function (as you demonstrated in the runRepeatedlyOnMainActor example), the compiler must then enforce that the caller doesn't capture any non-sendable type to form that closure.
This is the behavior today as the compiler can't tell how many times a closure is used, but a single-use closure wouldn't have this limitation.
And I'm left wondering: in a hypothetical world where closures can be annotated to specify they're only used once, does a function taking a multiple-use but sending closure like that make sense? It's sending, but the caller must be prevented from constructing a closure that captures a non-sendable value for this argument. So what benefit does it have over a @Sendable closure?
EDIT: Ah, I guess referencing is okay as long as it’s not sending again from within the closure