How do actors know how not to deadlock?

Hey everyone! I've noticed that actors can be quite smart, and I'm trying to know more about what exactly causes it. Consider the following code:

final class Foo: Sendable {
    let getBar: @Sendable () async -> Bar
    init(getBar: @escaping @Sendable () async -> Bar) {
        self.getBar = getBar
    }
}

final class Bar: Sendable {}

actor TestActor {
    func makeFoo() async -> Foo {
        let foo = Foo {
            await self.makeBar()
        }
        _ = await foo.getBar()
        return foo
    }

    func makeBar() async -> Bar {
        return Bar()
    }
}

func run() {
    let test = TestActor()
    Task.detached {
        await test.makeFoo()
    }
}

This test creates an actor and call one of its methods. The method creates a type Foo, injects itself into it via a closure, and then proceeds to call said closure. In other words, we're entering the actor, leaving it, and then attempting to enter it back again asynchronously and before returning from the original method.

In a normal situation I would expect this to deadlock. The main feature of actors is that they don't allow parallel access, so it feels like this round-trip shouldn't work. But it does!

It looks like Swift is smart enough to understand that since the call to re-enter originated from the actor itself, it can completely ignore the async annotations and run it synchronously and without zero locking. The WWDC sessions do mention that actors have this power, but I was surprised that it works even when attempting to "hide" the fact that we're re-entering it from the compiler. How does it know that in this case?

1 Like

I don't think there's anything surprising here. async functions on actors being re-entrant is what allows them not to deadlock in the first place, per the corresponding Swift Evolution proposal:

When an actor-isolated function suspends, reentrancy allows other work to execute on the actor before the original actor-isolated function resumes, which we refer to as interleaving . Reentrancy eliminates a source of deadlocks, where two actors depend on each other, can improve overall performance by not unnecessarily blocking work on actors, and offers opportunities for better scheduling of (e.g.) higher-priority tasks. However, it means that actor-isolated state can change across an await when an interleaved task mutates that state, meaning that developers must be sure not to break invariants across an await.

[...] non-entrancy can result in deadlock if a task involves calling back into the actor.

3 Likes

Right, I think I may just be confused by how Xcode's debugger works with async code. What I thought was interesting is that calling await sometimes resulted in the code waiting for the actor to finish whatever it was doing before running, while in the other times it seemed to result in the code running synchronously and immediately (including jumping in front of anything else that happened to be enqueued at the time) as if you had no annotations in the first place. But based on what @Max_Desiatov showed I suppose that the actor is suspending as usual and allowing the other work to get in, it's just that the way Xcode shows stack traces when you put a breakpoint in async code may give the impression that this is not what's happening.

I quite like your example. :slight_smile:

But what actually makes this method async?

Is it the fact that it is marked so, even though I can't see anything async going on inside it? Can the caller ever get suspended?

Yes, if this method is not inlined, it will switch to the actor's executor and back when called. If it's inlined the optimizer may be able to eliminate the executor hop.