Non-Reentrant Actors

Whenever I am writing code involving actors, I find myself wanting to do meaningful work on the actor, but end up introducing hard-to-catch bugs in the process. Take this simple example, as discussed on the Swift Open Source slack:

actor HeavyLifting {
    var cachedResult: String?
    func doHeavyLifting() async throws -> String {
        if let cachedResult { return cachedResult }
        let result = try await // load some resource and process it.
        cachedResult = result
        return result
    }
}

A naive developer (me) might assume that this is a perfectly adequate way to make sure to only do the "heavy lifting" once, and cache the result. The more astute reviewer (also me) might however notice that although this wouldn't result in catastrophic failure, it also won't actually protect the heavy lifting from being done more than once, as multiple concurrent requests to this method while it isn't loaded will each start kicking off a heavy workload as soon as the suspension point is reached in each of them.

A more adventurous developer (me again) does have a tool in their tool belt to solve this — unstructured tasks:

actor HeavyLifting {
    var heavyLiftingTask: Task<String, Error>?
    var heavyLiftingResult: String {
        get async throws {
            if let heavyLiftingTask { return try await heavyLiftingTask.value }
            let task = Task { try await // load some resource and process it. }
            heavyLiftingTask = task
            return try await task.value
        }
    }
}

Now, we get the behavior we were looking for, with an understanding that if we do stupid things while doing some heavy lifting, we may indeed deadlock ourselves. However, in the process, we've now introduced a delicate balance of incomplete boilerplate, not to mention a lack of proper support for cancellation propagation, all in the name of preventing deadlocks, while not actually stopping the adventurous developer from writing code that could potentially deadlock, but probably won't.

My code is littered with this boilerplate, and I am bound to eventually get it wrong, when at the end of the day, what I was ultimately hoping for, and have assumed I was doing before an errant await eventually sneaks in, was for a method on an actor to not be callable while it is in progress, while the rest of the actor goes about its business.

Hopefully now that we've lived with actors for a while, it is time to re-visit non-reentrancy in the context of what is most useful to have as a tool at our disposal. In my case, I could see individual methods or functions being something we may want to enforce serial access on in some capacity, but I can also see the benefit for it being applied to all access to the actor as a whole. Heck, maybe a macro could solve the first use case?

22 Likes

+1

I think the future direction as stated (with bonus points for task chain reentrancy, but absolutely very useful without) would be just fantastic as described there. I wouldn’t want it applied to the whole actor.

It’d definitely would have helped avoid what now easily becomes fragile patterns to work around the lack of this feature.

This together with optional actor “send” support for Void returning functions would unlock a significant chunk of the potential of actors IMHO.

5 Likes

I wish we could just mark a function as @blockingReentry to indicate that you audited the code for being safe to block reëntry and when called would not allow the actor to be reëntered (externally) until the function has concluded. Dreams

Here was discussion with great examples on how to implement everything using structured concurrency: Structured caching in an actor

The open question (as for me) is cancellation - it seems to be is a bit hard to properly implement, the rest seems to be OK, but I agree that restricting some serial access here would be much beneficial.

3 Likes