Isolation macro: documentation is incomplete?

I have been playing with the isolation macro quite a bit.

I am almost convinced that it only knows about the static isolation, but the documentation does not say anything about this.

Documentation

Macro

isolation()

Produce a reference to the actor to which the enclosing code is isolated, or nil if the code is nonisolated.

...

@freestanding(expression)
macro isolation<T>() -> T

Overview

If the type annotation provided for #isolation is not (any Actor)?, the type must match the enclosing actor type. If no type annotation is provided, the type defaults to (any Actor)?.

Here is the code

Driver.swift
//  Driver.swift

@main
enum Driver {
    static func main() async throws {
        IsolationInspector.inspect (prefix:"1")
    #if false
        Task {
            IsolationInspector.inspect (prefix: "2")
            await MainActor.run() {
                IsolationInspector.inspect (prefix: "3")
            }
            IsolationInspector.inspect ()
        }
        
        Task {@concurrent in
            IsolationInspector.inspect (prefix:"4")
            await MainActor.run() {
                IsolationInspector.inspect (prefix: "5")
            }
            IsolationInspector.inspect (prefix:"6")
        }
        
        Task {@GA1 in
            IsolationInspector.inspect (prefix:"7")

            Task {
                IsolationInspector.inspect (prefix:"8")
                await MainActor.run() {
                    IsolationInspector.inspect (prefix: "9")
                    w ("9")
                }
                IsolationInspector.inspect (prefix:"A")
                await f ("A")
                
                IsolationInspector.inspect (prefix:"B")
                await g ("B")
                
                IsolationInspector.inspect (prefix:"C")
            }
        }
    #endif
        await A1 ().run()
        try? await Task.sleep (for: .seconds(7))
    }
}

func f (_ x: String) async {
    IsolationInspector.inspect (prefix: x)
    try? await Task.sleep (for: .seconds(0.1))
}

@concurrent
func g (_ x: String) async {
    IsolationInspector.inspect (prefix: x)
    try? await Task.sleep (for: .seconds(0.1))
}

@globalActor
actor GA1 {
    static let shared = GA1()
}

actor A1 {
    func run () async {
        IsolationInspector.inspect (prefix: "\(type (of: self))")
        w ("\(type (of: self)): \(#function)")
        try? await Task.sleep (for: .seconds(0.1))
        IsolationInspector.inspect (prefix: "\(type (of: self))")
    }
}

func w (_ x: String) {
    IsolationInspector.inspect (prefix: x)
}

IsolationInspector.swift
//
//  IsolationInspector.swift
//
//

enum IsolationInspector {
    @inline(always)
    static func inspect (f: String = #function, prefix: String = "", line: Int = #line, _ u: (any Actor)?  = #isolation) {
        
        let x = type (of: self)
        if let u {
            print ("\(x):", "\(f): \(line): \(prefix): [\(u)]")
        }
        else {
            print ("\(x):", "\(f): \(line): \(prefix): [undefined]")
        }
    }
}

The output:

// main () -> A1.run () -> w (x)

IsolationInspector.Type: main(): 6: 1: [Swift.MainActor]
IsolationInspector.Type: run(): 66: A1: [IsolationInspector.A1] // in actor A1, call w (_:)
IsolationInspector.Type: w(_:): 74: A1: run(): [undefined]      // in sync w (_:), called by actor A1
IsolationInspector.Type: run(): 69: A1: [IsolationInspector.A1] // back in actor A1

// Legend:
//    A1: an actor
//    w(x): a synchronous function
//   [x] means 'x` is the isolation provided by the isolation macro


Why is the isolation undefined inside the w () function, called by actor A1?

Am I doing something wrong?

You are correct, #isolation resolves to the current static isolation.

In this example, w is a nonisolated function so #isolation is nil there. There is no way to retrieve the isolation inherited by a nonisolated synchronous function that I know of. And that is what's happening when the actor invokes w.

You can get at the isolation of an asynchronous function with the macro if you have NonisolatedNonsendingByDefault enabled.

2 Likes

Non-isolated synchronous functions have no notion of dynamic isolation; using the #isolation macro inside those functions will always resolve to nil.

SE-0461 does mention this explicitly:

Note that this introduces a semantic difference compared to synchronous nonisolated functions, where there is no implicit isolated parameter and #isolation always expands to nil. For example, the following program prints nil:

func printIsolation() {
  let isolation = #isolation
  print(isolation)
}

@main
struct Program {
  // implicitly isolated to @MainActor
  static func main() async throws {
    printIsolation()
  }
}
1 Like

Thank you both, @mattie & @NotTheNHK

This also works:

nonisolated(nonsending)
func p (_ x: String) async {
    IsolationInspector.inspect (prefix: x)
}

However, I must admit I find the result quite baffling: to see the macro resolving to isolated A1. The declaration says the function is nonisolated!

actor A1 {
    func run () async {
        IsolationInspector.inspect (prefix: "\(type (of: self))")
        w ("\(type (of: self)): \(#function)")
        try? await Task.sleep (for: .seconds(0.1))
        IsolationInspector.inspect (prefix: "\(type (of: self))")
        await p ("\(type (of: self)): \(#function)")
    }
}

func w (_ x: String) {
    IsolationInspector.inspect (prefix: x)
}

nonisolated(nonsending)
func p (_ x: String) async {
    IsolationInspector.inspect (prefix: x)
}
IsolationInspector.Type: main(): 6: 1: [Swift.MainActor]
IsolationInspector.Type: run(): 66: A1: [IsolationInspector.A1]
IsolationInspector.Type: w(_:): 75: A1: run(): [undefined]
IsolationInspector.Type: run(): 69: A1: [IsolationInspector.A1]
IsolationInspector.Type: p(_:): 80: A1: run(): [IsolationInspector.A1]

This is correct. This is dynamic isolation, nonisolated(nonsending) is the explicit spelling of NonisolatedNonsendingByDefault.

Yeah, the implementation of nonisolated(nonsending) is close to this:

func p (_ x: String, isolatedParam: isolated (any Actor)?) async {
  // here, #isolation will return the value of isolatedParam
}

(the difference is that a nonsending function cannot accept arbitrary actors, it can only be the caller)

So, more specifically it is not just because this function is nonisolated. That's not enough. It is because it is a (asynchronous) nonisolated (nonsending) function which inherits the isolation of the caller and also makes that isolation available inside the function body statically.

2 Likes

To expand on this a little further, let's look at the (somewhat simplified and made-up) SIL for p and p with an isolated parameter:

// p()
// Isolation: nonisolated(nonsending)
$@convention(thin) @caller_isolated @async (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()
bb0(%0 : $Builtin.ImplicitActor):
// p(isolatedParam:)
// Isolation: actor_instance. name: 'isolatedParam'
$@convention(thin) @async (@sil_isolated @guaranteed Optional<any Actor>) -> ()
bb0(%0 : $Optional<any Actor>):

As you can see, both variants are quite similar. While there are important differences between the two, both have an @sil_isolated parameter annotation, meaning that p, if isolated at all, becomes isolated to the actor that is passed in.

Now, let's look at the apply-side SIL (again, somewhat simplified and made-up) when p is called from a @MainActor isolated context and the argument passed to isolatedParam is the result of the #isolation macro:

apply p(MainActor.shared.getter) : $@convention(thin) @async (@sil_isolated @guaranteed Optional<any Actor>) -> ()

apply p(MainActor.shared.getter) : $@convention(thin) @caller_isolated @async (@sil_isolated @sil_implicit_leading_param @guaranteed Builtin.ImplicitActor) -> ()

In both cases, the current context's actor is passed to p and used as either the function's static or dynamic isolation.

If you'd like to explore this yourself, here's the code with the actual SIL: Godbolt.

1 Like

I'm sort of re-interpreting your question now. Perhaps the confusion is around being simultaneously "nonisolated" but also isolated to something at runtime?

I agree this is strange. And it's doubly so now that nonisolated(nonsending) is so close to an isolated parameter.

Here's my 30-second-of-thinking attempt at an explanation:

"nonisolated" is a static property that says, basically, "I'm making no promises about what actor, if any, I run on". But this does mean that, at runtime, there could in fact be an actor doing the executing. That actor is not statically accessible to synchronous functions, but it is to nonisolated(nonsending) functions (via the #isolation macro).

Edit: and to return to your original question, I think it probably does make sense to tweak that documentation so it more explicitly indicate that, being a compile-time construct, the macro can only see statically-visible isolation.

4 Likes