Function called isolated on MainActor via an isolated parameter are not considered run on the MainActor

If I try to compile the following, I get a compilation error:

import Foundation

func isolatedPrint<A : Actor>(on actor: isolated A) {
   print("hello")
}

Task{ @MainActor in
   isolatedPrint(on: MainActor.shared)
}

The error:

toto.swift:9:2: error: expression is 'async' but is not marked with 'await'
        isolatedPrint(on: MainActor.shared)
        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        await 
toto.swift:9:2: note: calls to global function 'isolatedPrint(on:)' from outside of its actor context are implicitly asynchronous
        isolatedPrint(on: MainActor.shared)
        ^

I don’t understand why the compiler does not detect the function is called on the MainActor via the actor parameter.

1 Like

Update: It was a bug. It works properly with Swift 6.

I wonder why it works? Is it because of specialization? If so, I think getActor() and hence isolatedPrint() can be specialized too in the following example, but why it doesn't compile?

func isolatedPrint<A : Actor>(on actor: isolated A) {
    print("hello")
}

Task{ @MainActor in
    // This doesn't compile. "await" is required.
    isolatedPrint(on: getActor(MainActor.shared)) 
}

func getActor<A: Actor>(_ a: A) -> A {
    return a
}

PS: Is it really useful to use global actor as generic type parameter in practice?

1 Like

The case in OP is understandable to me personally. I think the rule was roughly covered in this proposal originally, which was later refined as in this proposal. (FYI: I find the "local-binding-seeing-through" semantics in SE-0431 not completed implemented.)

I didn't find the exactly same example in the proposals text though.

Thanks. But the rules in SE-0420 and SE-0431 take effect at runtime, right? In OP's example, however, the parameter takes effect at compile time (otherwise isolatedPrint() should be called with await). That's why I think it might be because of specialization.

There is compile time "knowledge" about MainActor.shared and @MainActor equivalence, however if you erase it to just some Actor that's lost...

    await Task{ @MainActor in
        isolatedPrint(on: MainActor.shared) // OK
        
        isolatedPrint(on: getActor(MainActor.shared)) // lost ability to prove that this -> A is MainActor
    }.value

I would be surprused for the latter to work, since for all we know getActor could return anything and concurrency safety isn't conditional if we managed to see through some function like that or not.

To recover static information from dynamic runtime information you'd have to write MainActor.assumeIsolated to get that information back into static @MainActor.

2 Likes

Thanks for the explanation. Now that you said it, I realize it's unrelated to generics, as explained in SE-0420:

Since isolation is unavoidably value-dependent (an actor method is isolated to a specific actor reference, not just any actor of that type), polymorphism over it can't be expressed with just generics.

Actually I can reproduce the behavior in the example code using non-generic function:

func isolatedPrint(on actor: isolated C) {
   print("hello")
}

actor C {
    var value = 0

    func test() {
        Task {
            isolatedPrint(on: self) // This compiles by design.
        }
    }
}

So I re-read the sections in SE-0420 and SE-0431 suggested by @CrystDragon. I believe he was correct. The rules in those sections take effect at compile time indeed. Thanks @CrystDragon.

Out of curiosity, I did some experiments with actors and custom global actors. In actor experiments, I tried different approaches to pass self to an isolated parameter. These work (meaning: they compile):

  • self (of course)
  • self.self
  • through capture list: [self = self]. Variable name must be self, other names don't work.

and these don't:

  • thorugh function call
  • through compute property

The capture list test result is interesting. I can't think out how it works under the hood.

func isolatedPrint(on actor: isolated C) {
   print("hello")
}

actor C {
    var value = 0

    // This compiles
    func test1() {
        Task { [self = self] in
            isolatedPrint(on: self)
        }
    }

    // This doesn't compile (as expected)
    func test2() {
        Task { [self = C()] in
            isolatedPrint(on: self)
        }
    }

    // This doesn't compile
    func test3() {
        Task { [_self = self] in
            isolatedPrint(on: _self)
        }
    }
}

There is one thing I'm not 100% sure. I read in the forum a while back that, when calling an async func, there is no suspension happening if caller and callee have the same isolation (BTW, I think the behavior is explicitly mentioned in SE-0420. See the second paragraph in Motivation section). So, having await or not in the OP's example doesn't have any difference at runtime. That means the current behavior (no need for await) is only a usability/readability feature. Is this understanding correct?

BTW, I also found a crash when doing experiments and filed 81019 (perhaps low priority)