The definition of "task isolated"

(was: Why is it OK to capture an actor's mutable property in Task closure?)

Update: I later realized my real question was about the exact meaning of task isolated. See my summary here.


While trying something I find this compiles:

actor A {
    var value = 0

    func foo() {
        Task {
            value = 1 // This compiles
        }
        //print(value)
    }
}

I didn't expect it, because 1) as John said here, a value shouldn't be accessed concurrently in both actor isolation domain and task isolation domain, and 2) also, it's well known that similar code in a non-isolated function shouldn't compile:

nonisolated func foo() {
    var value = 0
    Task {
        value = 1 // This doesn't compile, as expected
    }
    print(value)
}

On the other hand, the example in question indeed have no data race. There is no cross-domain access to A.value, because synchronous code in a Task closure runs in the same isolation domain as the closure's caller. Also, if I try to access A.value from a different isolation domain, compiler catches it accurately. For example, this doesn't compile:

actor A {
    var value = 0

    func foo() {
        Task {
            await modify(&value) // This doesn't compile
        }
        print(value)
    }
}

nonisolated func modify(_ value: inout Int) async {
    value = 1
}

and this doesn't compile either:

actor A {
    var value = 0

    func foo(_ fn: sending @escaping ()->Void) {
        Task {
            fn()
        }
        print(value)
    }

    func bar() {
        foo {
            self.value = 1  // This doesn't compile
        }
    }
}

So, my confusion is not that compiler has wrong behavior but how to understand it. I wonder which rules in which Evolution proposals specified this behavior? One of my main confusions is that, although I'm aware that SE-0414 (region based isolation) mentioned that task isolated region applies only to nonisolated asynchronous function parameters, I usually think a mutable variable captured in Task closure is isolated to that task. Is my understanding incorrect?

1 Like

Task inherits the current isolation, in your first example it inherited your actor’s isolation. In your examples that do not compile you send the value to other isolation domains

I was aware of that. What I'm looking for is a simple explanation at a concept level which works for both scenarios (actor vs non-isolated function). In my experience most, if not all, behaviors in swift concurrency can be explained by simple rules (it's just that combinations of them are complex). I think that's important because it helps to avoid writing code in a "trial and error" approach. One such a simple rule is that a mutable value shouldn't be accessed from both actor isolation and task isolation, but unfortuntately it doesn't work for the first example. That caused me to be uncertain about the exact meaning of task isolated (I don't think it's defined in Evolution proposals). For example, does capturing value in Task closure in my first example cause it task isolated? I understand it's not always possible to get a simple rule and there are exceptions, but it would be great if anyone has a better explanation.


EDIT: I believe value isn’t task isolated, that’s why it cannot be passed to non-isolated function even if it has been captured by task closure. If so, my past understanding that a captured variable is task isolated is incorrect.

1 Like

From SE-0306:

A mutable instance property or instance subscript is actor-isolated if it is defined on an actor type.

value, a mutable instance property, is actor-isolated to A. See also, but name is immutable and therefore is not actor-isolated.

Also from SE-306:

A closure that is not @Sendable cannot escape the concurrency domain in which it was formed. Therefore, such a closure will be actor-isolated if it is formed within an actor-isolated context.

The Task closure is actor-isolated because it is formed within actor A. Therefore it’s safe to access value within Task's closure. Does this help?

i get the impression that 'Task-isolation' hasn't been as fleshed-out as a 'user-facing' part of the language as actor-isolation (though i think the terminology does appear in diagnostics...), so perhaps that's part of the cause of confusion. anyway, i'll try to go through your examples and give my take on them[1].

actor A {
    var value = 0

    func foo() {
        Task {
            value = 1 // This compiles
        }
        //print(value)
    }
}

as @hello_im_szymon mentioned, the Task body is isolated to the same actor instance as value, so there is no concurrency in this example (hence no diagnostics). crucially, the isolation inheritance in this case depends on the capture of the isolated self parameter – if it were not, the closure would be inferred to be nonisolated, and you'd get an error[2]:

actor A {
    var value = 0

    func foo() {
        let alias = self
        Task {
            alias.value = 1 // 🛑 actor-isolated property 'value' can not be mutated from a nonisolated context
        }
        print(value)
    }
}

these inference rules were documented in the proposal for the changes to support nonisolated(nonsending), and a relevant callout is this:

The closure is also inferred to be nonisolated if the enclosing context has an isolated parameter (including self in actor-isolated methods), and the closure does not explicitly capture the isolated parameter. This is done to avoid implicitly capturing values that are invisible to the programmer, because this can lead to reference cycles.

i think that inference rule helps explain a number of the differences here.

the inout example i think is slightly different:

actor A {
    var value = 0

    func foo() {
        Task {
            await modify(&value) // This doesn't compile
        }
        print(value)
    }
}

nonisolated func modify(_ value: inout Int) async {
    value = 1
}

IIUC, actor-isolated state cannot be passed inout to async functions due to a combination of the 'Law of Exclusivity' and 'actor reentrancy'. i.e. when you pass a value inout, the compiler must enforce exclusive access to the storage reference for the duration of the call. but if that call is async, it could suspend internally and allow other code to run on the actor which then tries to access that same storage reference, which violates The Law. i think rather than trying to perform dynamic book-keeping to allow this (which would potentially result in inscrutable runtime errors), it is just banned. or at least that's the model i've currently got – i asked about it here: Rationale for banning actor-isolated state from being passed `inout` to async functions?.

lastly, this:

actor A {
    var value = 0

    func foo(_ fn: sending @escaping ()->Void) {
        Task {
            fn()
        }
        print(value)
    }

    func bar() {
        foo {
            self.value = 1  // This doesn't compile
        }
    }
}

seems like it might at least be bug-adjacent, because the behavior appears inconsistent depending on how the closure is formed. e.g. this works:

actor A {
    var value = 0

    func foo(_ fn: sending @escaping () -> Void) {
        Task {
            fn()
        }
        print(value)
    }

    func bar() {
        let op: () -> Void = { self.value = 1 }
        foo(op) // ✅
        // foo({ self.value = 1 }) // 🛑
        // op() // ⁉️ also, this works and should presumably not due to `sending`
    }
}

  1. not that it's necessarily correct, but for mutual-learning purposes :) ↩︎

  2. note, the example here relies on current behavior that i think is sort of a bug-or-partially-unimplemented-feature of the 'generalized isolation checking' described here: Closure isolation capture seemingly can't see through aliases – expected or a bug? ↩︎

5 Likes

I think of two explanations. The difference is the definition of task isolated. TLDR: neither is perfect and it's feasible to think without using the term.

Explanation 1

  • Definition: A mutable value or a non-sendable value captured in a task closure is task isolated.
  • Rule: If a task is using a non-sendable value, but the same value is also accessible to any code that's isolated to a particular actor, then there's a potential data race unless the task and the actor are synchronized — which is to say, unless the task is running isolated to the actor. If the task leaves the actor, it must either leave the value behind or ensure that it's no longer accessible to the actor.

(Note: the rule is copied from John's post linked in my original post)

In this explanation it's possible that a value is both actor isolated and task isolated (see example 1 below).

Example 1:

actor A {
    var value = 0

    func foo() {
        Task {
            value = 1 // OK: the task is running isolated to the same actor.
			// await modify(&value) // Not OK, as expected
        }
    }
}

Example 2:

class NonSendable {}
@MainActor func transferToMain<T>(_ t: T) async { }
@concurrent func transferToGlobalExecutor<T>(_ t: T) async { }

func test1() {
    var x = NonSendable()

    Task {
        var x = NonSendable() // OK: the task is running isolated to the same actor
        await transferToGlobalExecutor(x) // OK: task leaves the actor, `x` isn't accessible to the actor during function execution
        await transferToMain(x) // OK: task leaves the actor, `x` is no longer accessible to the actor
    }
}

Note: await transferToMain(x) fails in practice. I filed #85639.

Explanation 2

  • Definition: A non-sendable parameter of a @concurrent function is task isolated.
  • Rule: A value can't be actor isolated and task isolated at the same time.

In this explanation "task isolated" effectively means the same as "running on global executor", or "running with no isolation", or "task isolated region (as defined in SE-0414)". As a result, a captured nonsendable value or mutable value in a Task closure isn't necessarily task isolated. Pro: the rule is simpler. Con: the name is confusing.

Example:

// I use the the same notation as in SE-0414
actor A {
    func test() {
        var value = 0 // {(value), self}
        Task {
            value = 1 // {(value, closure), self}
            await modify(&value) // {(value), Task}, {(closure), self}
            value = 1 // {(value, closure), self}
            await modify(&value) // {(value), Task}, {(closure), self}
            await B().modify(&value) // {(value), B()}, {(closure), self}
        }
    }
}

Summary

I suspect explanation 1 is what Apple engineers mean when they say "task isolated". The main reason I don't like it is that it feels problematic that a value can be both actor isolated and task isolated (perhaps that's why they avoid using the term?). Explanation 2 doesn't have this issue, but it's only about a portion of a task. There might be the third option: not using the term. I find the rule I quoted from John's post is extremely helpful and he didn't use the term directly.

PS: another thing that's worth mentioning. Unlike actor isolated, task isolated is a transient state.

1 Like

I have always been thinking of "something that is task isolated" just as a fancy way of saying "something that is neither isolated to a (locally) known actor, nor in a disconnected region". It never contradicts with my personally practice.

This roughly aligns with your Explanation 2, but I think of "non-sendable parameters of nonisolated async function are task isolated" as being the consequence, rather than the cause. Because the parameters can come from anywhere, and can alias with any bindings, we have to conservatively put them in a standalone region, which is named the "task region".

BTW, I also agree with your idea of the cons, because theoretically speaking, everything in Swift Concurrency already runs in some tasks.

Did you mean there are other scenarios in which a value is task isolated? I can’t think of any. Can you give an example? Thanks.

For example, for a non-sendable closure, its captured non-sendable values are in a task isolated region.


class NS {}

func foo() async {
    let x = NS()
    let closure: () async-> Void = {
        await passAnything(x)     // cannot send `x` into @MainActor 
                                  // here `x` is in a task isolated region, not in a disconnected region
    }
}

@MainActor
func passAnything(_ object: Any) {
}

Of course you can still regard x as some sort of special parameter, but I think it's easier to remember when x is used inside the closure, aliasing can happen.

class NS {
    func mutate() {}
}

func foo() async {
    let x = NS()
    let closure: () async -> Void = { [x] in
        await send(x)  // if the compiler does not reject this line, there will be an apparent race
    }
    await closure()
    x.mutate()
}

@MainActor
func send(_ object: NS) {
    Task {
        object.mutate()
    }
}
2 Likes

Thanks, it's a great example. Below I list a few more. In all of them transferToMainActor(x) call produces diagnostic like:

note: sending task-isolated 'x' to main actor-isolated global function 'transferToMainActor' risks causing data races between main actor-isolated and task-isolated uses

Common setup:

class NS {}

@MainActor func transferToMainActor(_ x: NS) async { }
@concurrent func transferToGlobalExecutor(_ x: NS) async { }

Example 1 (from SE-0414): passing async non-isolated func parameter to another async non-isolated func

func test1(_ x: NS) async {
  await transferToMainActor(x) // Not OK
  await transferToGlobalExecutor(x) // OK
}

Example 2: capturing non-isolated func parameter in a sending closure

func test2(_ x: NS) {
    Task {
        await transferToMainActor(x) // Not OK
        await transferToGlobalExecutor(x) // Not OK
    }
}

Example 3 (your example): capturing local variable in a sending closure

func test3() async {
    let x = NS()
    let closure: () async-> Void = {
        await transferToMainActor(x) // Not OK
        await transferToGlobalExecutor(x) // OK
    }
}

Example 4: same as your example, but variable and closure are actor isolated

actor A {
    func test4() async {
        let x = NS()
        let closure: () async-> Void = {
            await transferToMainActor(x) // Not OK
            await transferToGlobalExecutor(x) // OK
        }
    }
}

Example 5: Task.detached closure

class NS2 {
    var name: String = ""
}

@MainActor func transferToMainActor(_ x: NS2) async { }
@concurrent func transferToGlobalExecutor(_ x: NS2) async { }

func foo(x: NS2) {
    Task.detached {
        await transferToMainActor(x) // OK
        await transferToGlobalExecutor(x) // Not OK
        x.name = "abc" // Not OK
    }
}

Some observations:

  • All transferToMainActor() calls produce 'task isolated' diagnostic.

  • Not all transferToGlobalExecutor() calls are valid. For those invalid calls they produce diagnostic like the following, which doens't contain 'task isolated' term:

    error: passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure [#SendingClosureRisksDataRace]

  • I think SE-0414 provided an explanation on how to understand example 3:

    When a non-Sendable value is captured by an actor-isolated non-Sendable closure, we treat the value as being transferred into the actor isolation domain since the value is now able to merged into actor-isolated state

  • I also find SE-0414 has the following. So you're right that parameter of async non-isolated function isn't the only scenario.

    the closure and its non-Sendable captures are treated as being Task isolated since just like a parameter, both the closure and the captures may have uses in their caller

It appears to me that task isolated term only refers to a special case of sending a non-sending value to another actor isolation. In all examples x is non-sending, so it's invalid to call transferToMainActor() on it. Isn't this description more general and simpler than using the term? Or perhaps I don't grasp the nature of the term to see why it's useful?

The behavior of transferToGlobalExecutor(x) in above examples is also interesting. For all invalid example (example 2 and 5), it can be explained that x is non-sending. Among valid examples, x in example 3 and 4 is in disconnected region. What's interesting is example 1. It works because if it's safe to pass x to the caller in the first place, then it should be safe to pass it to the callee further. But hornestly speaking I thought of this explanation only after I knew the result. Perhaps this is the place where `task isolated' term is useful? For example, it can be explained this way: it's ok because caller and callee are in the same isolation (task isolation).

Below is my summary of the term's usefulness, based on my above understanding:

  • Why transferToMainActor() is invalid in both async non-isolated function and closure: not needed
  • Why transferToGlobalExecutor() is valid in async non-isolated function: maybe helpful
  • Why transferToGlobalExecutor() is invalid in closure capturing non-sendable value: not needed

I'm personally not going to use the term when reasoning about code.

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.