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?