Await + non-sendable callback violates actor isolation

Hello,

As part of my ongoing "list of things that compile but probably shouldn't" series, I came across a scenario that seemingly violates actor isolation:

Assume we start with the following producer which invokes a callback based on some isolated state:

actor _Producer {
    var val = 0
    func iter(_ cb: (Int) -> Void) {
        for i in 0..<4096 {
            assertIsolated()
            val += 1
            cb(val)
        }
    }
}

Which we then use with the following consumer:

actor _Consumer {
    let producer: _Producer
    init(_ p: _Producer) { producer = p }

    var val = 0
    func consume() async {
        await producer.iter() { newVal in
            val = newVal
            assertIsolated()
        }
    }
}

This compiles, and is legal as per 306 which states that:

However, await will immediately release the conceptual lock on the Consumer until it returns, and yet the callback is isolated to Consumer as per spec. On the other side, Producer cannot re-acquire the lock because, as far as it knows, cb() cannot possibly suspend.

I guess that this is a spec interpretation error, since an isolated closure cannot really be formed within the context of an await.

Full code here:

actor Producer {
    func iterNonSendable(_ cb: (Int) -> Void) {
        val = 0
        for i in 0..<4096 {
            assertIsolated()
            val += 1
            cb(val)
        }
    }
    func iterSendable(_ cb: @Sendable (Int) -> Void) {
        val = 0
        for i in 0..<4096 {
            assertIsolated()
            val += 1
            cb(val)
        }
    }
    func iterAsync(_ cb: (Int) async -> Void) async {
        val = 0
        for i in 0..<4096 {
            assertIsolated()
            val += 1
            await cb(val)
        }
    }
    var val = 0
}

actor Consumer {
    let producer: Producer
    init(_ p: Producer) { producer = p }

    var val = 0
    func consume() async {
        await producer.iterAsync() { newVal in
            val = newVal
            assertIsolated("async")
        }

        await producer.iterNonSendable() { newVal in
            val = newVal
            assertIsolated() // Incorrect actor executor assumption
        }

        await producer.iterSendable() { newVal in
            //val = newVal // Actor-isolated property 'val' can not be mutated from a Sendable closure
        }
    }
}

Task {
    await Consumer(Producer()).consume()
    print("done")
}

With complete concurrency checking enabled you get the warning (on both non-Sendable closure parameters):

Passing argument of non-sendable type '(Int) async -> Void' into actor-isolated context may introduce data races

So it seems like the compiler thinks this is a problem; it's weird it doesn't produce an error for it even with complete concurrency checking.

Yet the isolation assertion does pass. So it seems like it does produce correct code nonetheless.

I was curious what it actually generates, so I tried looking at the disassembly, but even for this trivial example there's an incredible amount of complexity, and I couldn't even find the key closures amongst the dozens of emitted closure procedures. A (tangential) important reminder that actors and async/await in general are expensive and should not be used frivolously.

Thanks for double-checking, I do have strict concurrency checking enabled on my app target, but didn't realize that I have to also enable it in Package.swift.

Just to make sure we're seeing the same result, when you say

You mean in the Producer and the iterAsync case, right? This fails for me on x86:

await producer.iterNonSendable() { newVal in
    val = newVal
    assertIsolated("non-sendable") // Incorrect actor executor assumption
}

__lldb_expr_11/MyPlayground.playground:42: Fatal error: Incorrect actor executor assumption; Expected 'UnownedSerialExecutor(executor: (Opaque Value))' executor. non-sendable

Though I guess that this will turn into a a compile error eventually, since it's already caught in strict mode.

Yeah, I had just realized just how much thread hopping is necessary here when I wondered "wait, but why does it compile without explicit async".

I'd expect Actors to be implicit dispatch queues with some associated cost, but I'm not convinced that async / await needs to be slow if no hopping is involved. Unless I'm missing something, they're just coroutines and as long as the local state to be saved / restored is reasonably sized, their performance should be comparable to a function call that spills registers to memory, no?

Yes; inside the closure argument to iterAsync.

Perhaps. I don't know what the actual implementation is - unlike e.g. GCD, the source code isn't apparent.

The implementation currently has quite a few unnecessary domain hops - which can be considered "missed optimisation opportunities" - as well as some controversial ones with significant performance impliciations (e.g. its predilection for forcing things off of the main actor). This is an area that seems to be getting significant attention from the compiler team. So :crossed_fingers: the overheads of async/await reduce substantially in the near future.

1 Like

I'm a big fan of GCD, I believe that it's a better abstraction than not just threads but, dare I say, actors?

async/await forcing things off the main actor feels natural to me: Swift had the option to adopt a @MainActor Promise based API as popularized by JS and instead chose the concurrency-aware spec. My guess is that, if implemented correctly, it has the ability to, if not redefine server concurrency, at least make Apple a player in the c10k server space and HPC.

As for missed optimization opportunities I'm happy to say that I, having had the same thought as you, benchmarked withTaskGroup() against DispatchQueue.concurrentPerform() to more or less the same result (dispatch is still a bit faster but not by much and in both cases most benefits come from adequately sizing batches, where TaskGroup seems to come out ahead)