While doing experiments, I sometimes use code like the following, but recently I realized the closure is quite misleading.
class NS {}
actor A {
let ns = NS()
func test1() {
let fn: @concurrent () async -> Void = { _ = self.ns }
// ...
}
}
Notes:
- I'll use
@concurrentclosures in the discussion for simplicity. I believe it applies tononisolated(nonsending)closures too. - I did experiments on Swift 6.2. I believe nightly have the same behaviors.
How does the closure work?
There are three completely different ways to understand the closure:
-
The closure runs on global executor.
self.nsis accessed synchronously.This was what I thought when I saw the source code. However, it isn't how the code actually works.
-
The closure runs on global executor.
self.nsis accessed asynchronously.This was what I thought during my investigation, even though I was aware that there wasn't allowed by rules in SE-0306.
-
The closure runs on the actor A's executor.
self.nsis accessed synchronously.This is how the code actually works. This is confusing too because the
@concurrentmodifier is just silently ignored. It's hard for user to tell that the closure actually runs inA's executor.BTW, a `@concurrent` closure capturing actor by calling its method also runs on the actor's executor.
It can be verified by looking at SIL that nonisolated synchronous func
baris executed on actor A's executor rather than on global executor in bothfn1andfn2below.class NS {} actor A { let ns = NS() func foo() {} func test() async { let fn1: @concurrent () async -> Void = { bar() _ = self.ns } let fn2: @concurrent () async -> Void = { bar() await self.foo() } // ... } } func bar() {}
I wonder if the behavior in item 3 is by design? The problem is that @concurrent modifier is silently ignored. In contrast, a @MainActor closure behaviors differently (@MainActor modifier isn't ignored).
Example: a `@MainActor` closure runs on `MainActor`'s executor, not `A`'s executor
This can be verified by checking fn's SIL. bar is executed on MainActor, not A's executor.
class NS {}
actor A {
let ns = NS()
func foo() {}
func test() async {
let fn: @MainActor () async -> Void = {
bar()
await self.foo()
}
// ...
}
}
func bar() {}
On the other hand, however, if the behavior in item 3 isn't correct, what should be correct? The behavior in item 2 doesn't look like correct, not only because it's confusing but also because it violates the rules in SE-0306.
So how about not allowing such closures? IMO the impact on existing code would be small because a) it's not common to capture actor properties in practical code, and b) in projects that have such code, I believe those closures can be replaced with function calls.
I think a more general underlying question is that, when a closure both captures isolated parameter and is marked with @concurent or @MainActor modifiers, what's the expected behavior? In addition, do implicit self and explicit isolated parameter have different behaviors in these cases? I didn't find discussion about this in Evolution proposals.
Another example in which `@concurrent` is silently ignored
fn closure is actor-isolated. That's why it can't be passed to bar. await fn() runs fn on A's executor, not on global executor.
I also noticed that, unlike closure that capturs A's property, fn's SIL doesn't have a hop_to_executor instruction (IIUC it's because it doesn't capture self).
class NS {}
actor A {
func test(_ ns: NS) async {
let fn: @concurrent () async -> Void = { _ = ns }
await fn() // OK
bar(fn) // Not OK
}
}
@MainActor
func bar(_ fn: @concurrent () async -> Void) async {
await fn()
}
The closure's sendability
If we reason about the clode at source code level, it makes sense that fn1 isn't Sendable, because the closure captures a actor-isolated non-Sendable value (this is inaccurate. More on this later). That was how I thought about it in the past.
class NS {}
actor A {
let ns = NS()
func test() {
let fn1: @Sendable @concurrent () async -> Void = { _ = self.ns } // Not OK
let fn2: @Sendable @concurrent () async -> Void = { await self.foo() } // OK
// ...
}
func foo() {}
}
However, if we look at fn1 SIL and find the entire body is effectively wrapped in an asynchrously call, there is no reason why it isn't Sendable. I can even think of an explanation at source code level: the closure captures self, which is an actor and Sendable, so the closure is Sendable too (this is also inaccurate. See below).
But what about self.ns? Doesn't it leak the reference of ns to a different isolation? IMO it would only be an issue if the closure is marked with, say, @MainActor modifier. For nonisolated async closure (let's ignore that fact @concurrent modifier is actually ignored for the moment), it's a non-issue because a) it's impossible for a nonisolated async closure to store reference of ns, and b) all accesses to ns inside the closure body actually happen on A executor (this is guaranteed by the rule in SE-0306 that actor properties shouldn't be accessed across isolation).
So it appears to me that a nonisolated async closure capturing actor properties is Sendable.
Even though the language doens't allow transferring a closure like { _ = self.ns } to a task closure, it can be worked around. The following code compiles.
class NS {}
actor A {
let ns = NS()
func foo() async {
let fn: nonisolated(nonsending) () async -> Void = { _ = self.ns }
await bar(fn)
}
func bar(_ fn: @concurrent () async -> Void) async {
await fn()
}
func test() {
Task {
await foo()
}
Task {
await foo()
}
}
}
So, IMO either the closure is Sendable or the approach used in the workaround shoudn't be allowed.