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

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