I have finally put all pieces together and come up with a resonal explantion. I hope this will be my final summary :)
It's a region, not domain
This confused me a lot. For a value in actor isolated region, it has to be in actor isolation domain in the first place. So when I read "task isolated region" section in SE-0414, I thought there was also a "task isolation domain" which might be related to code running in global executor. That's completely wrong. There isn't such a domain. A task isolated region can be in either actor isolation domain or nonisolation domain.
The necessicty for the region
The region represents a type of value which has the behavior described by the last row of the table:
|
transfer to another actor |
transfer to global executor |
| disconnected |
yes |
yes |
| actor isolated |
no |
no |
| task isolated |
no |
yes |
The term's name is spot-on because it accurately captures the two behaviors:
- The value can be passed to an async nonisolated funciton, because the function call belongs to the same task
- The value can't be passed to an actor isolated function, because that would cause it to be accessed by another task.
Parameter of async non-isolated function
This is a simple scenario. The parameter is task isolated:
- Since it's OK t pass it to the current function, it should be OK to pass it further to another async nonisolated funciton.
- The current function has no knowledge of how the parameter is used by its caller, so it's not OK to pass it to another actor.
Closure captured value
Captured function parameter
The parameter is task isolated for the same reason as above. The last line is a test - since closure is current task isolated, it can't be used by another task.
func testA(_ x: NS) async {
let closure: () async-> Void = {
//await transferToMainActor(x) // Not OK
await transferToGlobalExecutor(x) // OK
}
Task { await closure() } // Not OK
}
Captured local variable (disconnected before the capture)
func testB() async {
let x = NS()
let closure: () async-> Void = {
//await transferToMainActor(x) // Not OK
await transferToGlobalExecutor(x) // OK
}
Task { await closure() } // OK!
}
This is an interesting case because it's OK to pass closure to another task (see last line). It took me quite a while to think of a nice interpretation, which I consider as an unspoken rule in SE-0414:
A task isolated region doesn't have to stick to the current task. It's OK for another another task (BTW, the task closure is in the same isolation domain) to use values in the region as long as the original task stops using them.
Captured parameter vs captured local variable: why they have different behaviors?
The subtle difference is because function parameter is always current task isolated, while a closure capturing local variable can be used by another task in the same isolation domain.
Async nonisolated function vs closure
Why closure is more difficult to reason about?
It's because closure may capture parameter or local variable, while funciton doesn't have this difference (it can only takes parameters). That, plus the fact that captured local variable is more difficult to reason about than captured parameter, makes it's more difficult to reason about closure in general.
Why function must be nonisolated, but closure can be actor isolated?
First if we look at task isolation region's defintion, there is nothing requiring the value must be non-isolated. As mentioned earlier, it can be either non-isolated or actor-isolated.
Example: x and closure are in task isolation region in actor isolatio domain
actor A {
func testC() async {
let x = NS()
let closure: () async-> Void = {
await transferToMainActor(x) // Not OK
await transferToGlobalExecutor(x) // OK
}
}
}
As for why function must be non-isolated, it's because if the function is actor isolated, its parameter must be actor isolated too and can't be transferred either a different actor or global exeuctor. Closure is special because the captured variable may be in originally in disconnected region.
Applying the term in practical examples
Now that I have a better understanding of the term, I find it's almost instant to reason about some code that would take more time otherwise. Below I list a few more:
transferToGlobalExecutor(x) call in this example is invalid because x is current task isolated so it can't be used in another task. In contrast, it would be OK if x is a local variable.
func testD(x: NS) {
Task.detached {
await transferToMainActor(x) // Not OK
await transferToGlobalExecutor(x) // Not OK
}
}
Another exmaple. This is the one in my original question. Now I know that x isn't task isolated but actor isolated.
actor A {
var x = NS()
func foo() {
Task {
x = NS() // OK
await transferToGlobalExecutor(x) // Not OK
}
}
}
More thoughts
The current design handles captured parameter and captured local variable in a different way (see testB). While I have no idea on how to do it, I wonder if it's possible to go further to allow transferToMainActor(x) in testB too. I mean something like the following:
func testB2() async {
let x = NS()
let closure: () async-> Void = {
await transferToMainActor(x)
}
Task { await closure() }
}
Of course if this was really supported in future, x wouldn't be considered task isolated any more and a new concept would be required. I actually filed a bug a few days ago. Now that I understand why it's so I'm going to close it.