Thanks. I see what you meant. I also found this behavior was mentioned in Evolution documents, but I still have doubts about the inconsistency. Please read on (I'll focus on task closure).
My Summary of Task closure isolation inference
During the discussion I found I didn't really understand task closure isolation inference, so I found relevant sections in Evolution documents. It turns out my questions were all answered in them. Below I'll first list what I found, then I'll discuss a very minor issue (IMO) in the rules in SE-0420, which leads to an inconsistent behavior mentioned in my earlier post.
I found task closure isolation inference is mainly discussed in three Evolution documents:
- SE-0304: Structured concurrency
- SE-0420: Inheritance of actor isolation (it's based on SE-313)
- SE-0472: Task.immediate API
Among them SE-0304 and SE-0420 defined rules and SE-0472 gave examples.
1) The original rules in SE-0304
If called from the context of an existing task:
- the closure passed to
Task {} becomes actor-isolated to that actor, allowing access to the actor-isolated state, including mutable properties and non-sendable values
If called from a context that is not running inside a task:
- execute on the global concurrent executor and be non-isolated with regard to any actor
IIUC this was the original rationale behind the behaviors of @__unsafeInheritExecutor. I found it simple and easy to follow. For example, it explains why the task closure in the following tests is inferred as MainActor, instead of nonisolated.
class NS {}
@concurrent func send(_ x: NS) async { }
@MainActor
func testA() {
let x = NS()
Task { // Inferred as MainActor
await send(x)
}
}
2) isolated parameter support in SE-0420
According to [SE-0304][SE-0304-propagation], closures passed directly
to the Task initializer (i.e. Task { /*here*/ }) inherit the
statically-specified isolation of the current context if:
- the current context is non-isolated,
- the current context is isolated to a global actor, or.
- the current context has an
isolated parameter (including the
implicit self of an actor method) and that parameter is strongly
captured by the closure.
The third clause is modified by this proposal to say that isolation
is also inherited if a non-optional binding of an isolated parameter
is captured by the closure.
Since the new rules were intended to extend the old ones, I think they could be defined by extending the definition of context:
The current context consists of caller's isolation and isolated parameter captured by the closure (if there is). isolated parameter has higher priority.
The benefit of defining it this way is that it's compatible with the old rules.
That said, I think I understand why the authors defined the new rules in its current way, perhaps because it's good to be specific. For example, since it's unlikley that a non-isolated or global actor isolated function can have an isolated parameter, they are defined in separated rules than actor isolated methods. However, the problem with this approach is that it leaves ambiguity. For example, the above rules doesn't cover the following case:
actor A {
func testB() {
let x = NS()
Task { // It's inferred as nonisolated in practice.
await sendToGlobalExecutor(x)
}
}
}
As a result it's up to the implementation to decide how testB should behave and hence the inconsistency between testA and testB. In contrast, if the rules were defined in the way I proposed, there would be no ambiguity (testB should be inferred as actor isolated).
Note: The above are all my opinion. It's almost certain that testB behavior was discussed and reviewed inside Swift team. But as an user who has no knowledge of that discussion I really can't see why it behaves the way it's (at least this doesn't follow the original rationale in SE-0304 and I can't find it's explicitly mentioned in SE-0420).
3) isolated parameter whose value is unknown at compile time
This section is only for completion purpose. Task closure is inferred as nonisolated at compile time (this is true even if testC is an actor method) and runs in the same isolation as the isolated parameter at runtime.
func testC(_ isolation: isolated (any Actor)?) async {
let x = NS()
Task {. // Inferred as nonisolated at compile time.
_ = isolation
}
}