Why is a Task closure defined in an Actor's method nonisolated?

AFAIK a Task closure doens't follow the isolation inferrence rule described in SE-0461 because of @_unsafeInheritExecutor attribute. So it inherits caller's isolation. This makes sense because it makes it convenient to implement a task inside an actor (see test1 for example). However, I don't understand why the Task closure in test2 is nonisolated. I'm aware that the closure in test2 doesn't access self. But does this mean @_unsafeInheritExecutor's behavior is dynamic? Where can I find document about it?

I'm asking because while the difference looks trivial, it leads to weird behaviors if foo is @concurrent. I'll ignore the details here to keep my question simple.

class NS {}

func displayIsolation(isolation actor: (any Actor)? = #isolation) {
    print(actor ?? "nonisolated")
}

@concurrent func sendToGlobalExecutor(_ x: NS) async {
}

actor A {
    func foo(_ x: NS) async {
    }

    func test1() {
        let x = NS()
        Task {
            displayIsolation() // output: A
            await foo(x)
        }
    }

    func test2() {
        let x = NS()
        Task {
            displayIsolation() // output: nonisolated
            await sendToGlobalExecutor(x)
        }
    }
}

let a = A()
await a.test1()
await a.test2()
try? await Task.sleep(for: .seconds(1))

Side note: while I did experiments on Swift concurrency frequently, it only occurred to me today that I can use a tool like displayIsolation() to display isolation in code. I wonder what are the approaches used by others? I'm aware of Actor.assertIsolated, but I personally find being able to print isolation is also useful (for example, it can be used to verify if code is nonisolated). I wonder why stdlib doesn't provide a function like this.

1 Like

The difference is that self is captured in the closure in test1(), which is not true for test2. This makes a difference when the compiler is inferring isolation for a @_inheritActorContext closure.

1 Like

That’s my guess too and it does makes sense. Did you know it before or was it because you saw my test? :smiley: The problem is that when the code is mixed with other code it’s not that obvious unless one knew it before.

The behavior has been documented by the undersocred attribute doc here, and was brought up several times in the forum and certain proposals (sometimes as annotations), like this and this.

3 Likes

Yeah, the conditional nature of @_inheritActorContext has surprised and confused many people. There is a non-conditional variant, @_inheritActorContext(always), however that isn’t what the task initializers use.

It has taken a while, but I still believe this is an area of the language that will be improved.

While I do find myself doing it less and less these days, I usually just use print("actor:", #isolation) directly.

1 Like

Thank all.

Yes, I later realized this too. I use it to verify that a function really runs on global executor when in doubt (e.g. when doing experiments or investigating bugs).

1 Like

Mr. Wu already gave an excellent answer. However, I would like to elaborate further on why I think this is actually consistent (at least until recently, with the introduction of nonisolated(nonsending) ) with closure isolation rules in general. As you probably know, closures formed within an isolated context that have a notion of isolation (i.e., async closures) are nonisolated by default (except when the isolation is derived from a global actor):

func noIso() async {
    let c = { () async -> Void in 
        print("noIso", #isolation as (any Actor)?)
    }

    await c() // nil
}

@MainActor func isoToMain() async {
    let c = { () async -> Void in 
        print("isoToMain", #isolation as (any Actor)?)
    }

    await c() // MainActor
}

func isoToSomething(_ iso: isolated (any Actor)?) async {
    let c = { () async -> Void in 
        print("isoToSomething", #isolation as (any Actor)?)
    }

    await c() // nil
}

As with Task, you can capture the context’s isolation to isolate the closure like this:

 func captureIsoInClosure(_ iso: isolated (any Actor)?) async {
    let c = { () async -> Void in 
        _ = iso
        print("captureIsoInClosure", #isolation as (any Actor)?)
    }

    await c() // iso isolated
}

However, you need to capture the isolation directly, as aliasing doesn’t work:

func failedToCaptureIsoInClosure(_ iso: isolated (any Actor)?) async {
    let c = { [iso] () async -> Void in
        print("failedToCaptureIsoInClosure", #isolation as (any Actor)?)
    }

    await c() // nil
}

The difference between “normal closures” and “Task closures” is that the latter allows you to isolate even @Sendableclosures. An @Sendable closure normally represents a nonisolated task, i.e., the closure executes on the GCE:

actor A {
    func doSomething() {}

    func f() {
        let c = { @Sendable () async -> Void in 
            self.doSomething() // cannot be called from outside of its isolation
        }

        Task {
            self.doSomething() // Okay
        }
    }
}

Personally, I think this is expected behavior if you’re aware of the general rules of closure isolation. However, I also think it is overall terribly confusing and needs further improvement. SE-0461 was already a step in the right direction, as it got rid of SE-0338 (which in hindsight was a rather poorly thought-out change, in my opinion).

As for your other question, I think all four methods, that I can come-up with, have their place: #isolation, assertIsolated/preconditionIsolated, wrapping partial task in print statements, and examining the SIL.

P.S. You might find the Godbolt examples in this comment interesting: [Pre-Pitch]: updating `with{Checked|Unsafe}Continuation` to support typed throws (and perhaps nonisolated(nonsending)) - #8 by NotTheNHK

2 Likes

I found this result pretty surprising. I did not realize that the capture of an isolated parameter would influence a non-@_inheritActorContext closure. The most comprehensive documentation for this can be found in SE-0461, which does explicitly call this out.

I’m firmly in the “isolation inheritance should not depend on captures” camp, but I have to admit that I would need to think harder about this case. However, my gut reaction is that the conditional behavior as it relates to isolated parameters is even worse than that of `@_inheritActorContext`, because it is not explainable via an attribute, but instead only by way of implementation.

I don’t completely follow here. The closure passed to Task here is not @Sendable. And if you were to explicitly make it @Sendable, it would have the same problem.

That’s true. However, the closure passed to Task must be safe to execute concurrently. And that’s exactly what @_inheritActorContext solves (I think):

actor A {
    func doSomething() {}

    func f() {
        Task { @Sendable in
            self.doSomething() // Okay
        }

        run1 { @Sendable in
            self.doSomething() // actor-isolated instance method 'doSomething()' cannot be called from outside of the actor
        }

        run2 { @Sendable in 
            self.doSomething() // Okay
        }
    }
}

func run1(_ body: sending @escaping () async -> Void) {
    Task<Void, Never>(operation: body)
}

func run2(@_inheritActorContext _ body: sending @escaping () async -> Void) {
    Task<Void, Never>(operation: body)
}

@_inheritActorContext is intended to be used with @Sendable or sending function parameters. If you declare a function with a parameter marked as @_inheritActorContext that is neither @Sendable nor sending, you will get the following diagnostic:

warning: @_inheritActorContext only applies to 'sending' parameters or parameters with '@Sendable' function types; this will be an error in a future Swift language mode

P.S. Sorry for the rather unsatisfying short reply. I initially wrote a much longer response, but the more I researched and experimented, the more inconsistencies I found.

P.P.S. I wonder how many folks know that you can isolate closures, and that this has nothing to do with @_inheritActorContext.

P.P.P.S. I think it would be an interesting idea to create an application that neatly visualizes where code executes with some beautiful, dynamic graphs.

FWIW, I found by accident that if added @MainActor to test2, the task closure was inferred as MainActor. This is different from @NotTheNHK's second example because task closure is sending. This behavior of @_unsafeInheritExecutor isn't mentioned in documents referenced in @CrystDragon's post. It's likely a bug but there is no way for us to determine it. I'll let it go.

+   @MainActor
    func test2() {
        let x = NS()
        Task {
            displayIsolation() // output: MainActor
            await sendToGlobalExecutor(x)
        }
    }

From my understanding this is the expected behavior. Remember that Task’s operation function parameter is marked with @_inheritActorContext, thus allowing it to inherit the caller’s isolation despite operation being @Sendable or sending.

P.S. Here is a very unorganized Godbolt example: Compiler Explorer

It seems there was a bug with the isolated keyword prior to Swift 5.8, where closures formed within a function with an explicit isolated parameter were always nonisolated. This was not the case for closures formed within a global-actor isolated context or actor methods. I wonder whether this bug substantially contributed to the confusion.

1 Like

But you forgot that just because a closure is marked @_inheritActorContext doesn’t necessarily mean it inherits the caller’s isolation. See my original test 2 behavior. The problem is that the behaviors of the original test 2 and the modified version are inconsistent, unless there is another unknown rule about @_inheritActorContext ( I hope not).

I only mentioned it briefly in my first comment: closures formed within global-actor isolated contexts have “special behavior.” By default, even when closures don’t capture isolation, they are isolated to that global-actor. However, this does not apply when the closure is passed to a sending function parameter or when the closure is marked @Sendable. If the closure is marked with @_inheritActorContext, it once again inherits the surrounding actor isolation:

@MainActor 
func test1() {
    _ = { () -> Void in
        print(1, #isolation) // MainActor isolated
    }()
}

@MainActor
func test2() {
    _ = { @Sendable () -> Void in
        print(2, #isolation as (any Actor)?) // nonisolated
    }()
}

func run1(_ action: () -> Void) {
    action()
}

func run2(_ action: @Sendable () -> Void) {
    action()
}

@MainActor
func test3() {
    run1 {
        print(3, #isolation) // MainActor isolated
    }
}

@MainActor
func test4() {
    run2 {
        print(4, #isolation as (any Actor)?) // nonisolated
    }
}

func run3(@_inheritActorContext _ action: @Sendable () async -> Void) async {
    await action()
}

@MainActor
func test5() async {
    await run3() {
        print(5, #isolation) // MainActor isolated
    }
}

P.S. You might have noticed that in the nonisolated cases I cast #isolation. That’s because global-actor isolation is static, and the macro expands to either the actor or nil, statically, if there is no isolation. Since you can’t print nil, this is an additional way to determine whether something is statically isolated.

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
    }
}
2 Likes

A caveat about using #isolation. It appears that it's evaluated at compile time when compiler can determine the current function's isolation statically and evaluated at runtime when compiler can't. See currentIsolation2 in the example. It outputs incorrect result.

Is this a bug or a feature? IMO #isolation should always be evaluated at runtime.

func currentIsolation1(isolation actor: (any Actor)? = #isolation) -> String {
    return "\(actor, default: "non-isolated")"
}

func currentIsolation2() -> String {
    return "\(#isolation as (any Actor)?, default: "non-isolated")"
}

class NS {}

actor A {
    let ns = NS()
    func foo(_ fn: nonisolated(nonsending) (NS) async -> Void) async {
        await fn(ns)
    }
}

nonisolated(nonsending)
func fn(_ ns: NS) async -> Void {
    print(currentIsolation1())        // A
    print(currentIsolation2())        // non-isolated
	print(#isolation as (any Actor)?) // Optional(A)
}

let a = A()
await a.foo(fn) 

EDIT: I filed #87671 and #87672.

1 Like

That seems to be a bug. The interaction between #isolation, nonisolated(nonsending), and Swift 5 causes #isolation to erroneously assume that the actor passed in from nonisolated(nonsending) represents the function’s static isolation. In contrast, Swift 6 correctly determines that they are all statically nonisolated. #isolation represents static isolation, whereas nonisolated(nonsending) represents dynamic isolation, or more precisely the executor on which the function should execute at runtime. However, the same erroneous behavior (in my opinion) appears in the devsnapshot and nightly builds. Perhaps I am misunderstanding something, but if that is true then I am quite confused.

cc @jamieQ @ktoso

Heh well, #isolation is NOT a runtime operation. We just do not offer a way to "get" the current "dynamic" execution context. (how I personally call it, though we've not actually codified this anywhere in public APIs).

The custom global executors pitch [Pitch 3] Custom Main and Global Executors was going to include currentExecutor which is what we can actually offer here however it's not gone through review due to a number of tricky questions under debate still... (current Executor part)

2 Likes