Why this actor call doesn't need await?

Hello, while migrating to Swift 6 I've found this case where the compiler warns of an unnecessary await and compiles alright without it, even thought I don't see how not having the await is correct. I would expect a compilation failure.

Tested with Xcode 16.0 beta 3 and with Swift 6.0 Development Snapshot 2024-07-19 (a)


public struct StructClient: Sendable {
    public var fetch: @Sendable (Int) async -> [Int]
    public var save: @Sendable (Int) async -> Void
}

actor ActorClient {
    var count = 0
    // note how this actor method is async, but the "StructClient" equivalent method is  is async
    func fetch(_ n: Int) -> [Int] {
        count += 1
        return [0]
    }
    
    func save(_ n: Int) async {
        count += 1
    }
}

public extension StructClient {
    static let shared: Self = {
        let actor = ActorClient()
        let s = StructClient(
            fetch: {
                try? await Task.sleep(for: .seconds(1))
                return await actor.fetch($0) // COMPILER WARNS: No 'async' operations occur within 'await' expression
            }, save: {
                await actor.save($0)
            }
        )
        return s
    }()
}

See on the specified line how the compiler warns that the await is not necessary. But the method is on an actor, so I would have expected the await to be needed as to indicate a potential suspension point to change into the actor isolation domain.

I can try to convince myself this is correct but I don't see how, all my theories have flaws :joy: So raising this post to see if somebody else has some insight.

If its legit I would love tounderstand how the compiler concludes it's safe, and if it's a bug I could raise it so hopefully is fixed.

Thanks <3

4 Likes

There was similar thread: https://forums.swift.org/t/calling-actors-method-in-an-async-closure-doesnt-allow-await-now/. To me it seems like a bug, as SIL actually generates hop to actor's executor.

Strongly capturing a single actor in a closure results in the closure being isolated to that actor, so the hop to the actor happens when the closure is called and then the actor can be accessed synchronously inside the closure.

I'm somewhat unclear on how intended this behavior is. The proposed behavior seems to have been significantly more limited than what got implemented.

4 Likes

Oh, I think I got it. That's might be due to the Task behaviour:

actor SomeActor {
    var x: Int = 0
}

func foo(isolation: isolated SomeActor) {
    Task {
        // capturing actor inside will make Task run on the actor
        // allowing to control Task execution
        let x = isolation.x
    }
}

So seems intended.

I thought about that too but I'm not sure how it can work when there are multiple actors involved. See

... rest of code as in original message

public extension StructClient {
    static let shared: Self = {
        let actor = ActorClient()
        let actor2 = ActorClient()
        let s = StructClient(
            fetch: {
                try? await Task.sleep(for: .seconds(1))
                // see how there are 2 calls to different actors and yet no await is needed
                print(actor2.fetch($0))
                return actor.fetch($0)
            }, save: {
                await actor.save($0)
            }
        )
        return s
    }()
}

The rule is that closures passed to the primary Task initializer[1] from an isolated context inherit that isolation. Currently there is an exception that this does not happen when the outer context is actor-isolated and the closure doesn't capture that actor, but we've discussed removing that exception because it's quite confusing in practice.


  1. in general: when a closure expression is used directly as the argument for an @_inheritsActorContext parameter; but that is not an official feature, and proposal documents always talk about Task.init ↩︎

3 Likes

Apologies John but I'm not sure I understand how that relates to my original snippet :thinking: In there there is no usage of Task.init. Maybe it was a reply to @vns post which does mention the Task.init behaviour.

If it indeed relates to my question I will dig again. I understand what you are referring to, I remember it from the recent proposals and discussions threads, just not sure it applies to my case here.

Cheers

For your example, I don't understand why the compiler would think the call to fetch is not cross-actor. We do see that in some cases because some function is incorrectly not @Sendable, but that isn't the case here. It may be a bug.

5 Likes

AFAICT the Task.init behavior is applying either to all closures or some additional subset of closures but I'd be very unsurprised if I'm just misunderstanding something.

This is just a bug in the implementation of the IsolatedDefaultValues feature, which tries to infer the isolation of an initializer expression. If you move the code out of a stored property initializer and into a method, the effects checker does not emit the warning.

8 Likes

That’s exactly the behaviour I was seeing! Thanks for confirming it’s a bug, but specifically thanks for validating my understanding of how the checker is supposed to work :heart:

Is there a bug we can track, or do we know if will be fixed in the next beta? No worries if is not knowable, i will keep an eye on new betas :)

2 Likes

I dug into this a bit more and it's just an ordering problem. For variable initializers specifically, the effects checker is running before actor isolation checking. The actor isolation checker is what marks specific calls as crossing actor boundaries. Without that, the effects checker thinks that no isolation boundary is crossed. The good news is this only impacts the diagnostics from the effects checker; the computed isolation is still correct, which is why you're still seeing the executor hops emitted in the generated SIL.

I think there's an existing GitHub issue for this. I'll try to find it.

9 Likes

@hborla Were you able to find the GitHub issue? I'm still seeing a similar issue in Xcode 16.3, where an await is unexpectedly not required when in a variable initializer, but the await is required if I copy the same code to a static function. If this was resolved, I can try to make a minimal example reproducing the regression.

Yes, the issue was Invalid async call in default initialized parameter · Issue #73892 · swiftlang/swift · GitHub and the PR that resolved it was [Sema] Fix an issue with the ordering of effects checking and actor isolation checking. by hborla · Pull Request #76988 · swiftlang/swift · GitHub. This fix is included in Swift 6.1 so please file a new GitHub issue if you're still seeing unexpected behavior.

Well, this:

actor SomeActor {
    var foo: Int? = nil
}

struct SomeStruct {
   let bar: Int = {
        let myActor = SomeActor()

        func foobar() async {
            guard let foo = await myActor.foo else {
                return
            }
            
            print("Foo: \(foo)")
        }

        return 0
    }()
}

Is enough to reproduce the issue I'm seeing. but I'm having trouble reproducing it outside of my package when running swiftc directly.

The behavior I'm seeing is, I am getting an error if I don't include the await, but including the await gives me this warning:

No 'async' operations occur within 'await' expression [no_async_in_await]

I'll try and see why swiftc isn't giving me that warning.

EDIT: The warning was happening when emitting the module, I just needed to pass a -emit-module flag to swiftc to reproduce the warning on the minimal example.

I've created another GitHub issue here. Thanks for the help @hborla.

2 Likes

Thank you! I'll take a look.