I was quite excited when actors were added to Swift, because much of my code is already structured with actor-like classes, utilizing OperationQueue
or DispatchQueue
. Unfortunately, they have fallen short of my expectations, and I couldn't use actors to replace my existing code. Let me explain...
Generally, I will use asynchronous Operation
types to fully serialize and atomize high level operations. In this way, implementing a high level asynchronous function like a sync
, which performs many different async calls to servers, local databases etc, can still be reasoned about simply: it is an asynchronous function call, but I know that it is fully serialized and atomic. It is not possible for two sync
calls to run concurrently, even though the function is asynchronous. It is almost as easy to reason about as serial code (if uglier for all the callbacks).
This is how I expected actors to work too, but it isn't how they work. Calls to async actor methods are not atomic. Anywhere there is an await
in an actor func, there is a potential for interlacing. In my example above, if the sync
function were called multiple times in short succession, they would run concurrently. Not concurrently in the threading sense, but concurrently in the sense that two executions of the method would be inflight at the same time. So even though the actor guarantees execution is serial, there is concurrency of function calls.
So what? IMO, this concurrency makes it just as difficult to reason about actors as it was to reason about thread races. Actors were supposed to protect shared data, and make it easy to reason about, but the lack of atomicity of functions mean it is just as difficult to reason about actor races as thread races.
To make this more concrete, here is a contrived example, but one which demonstrates the problem as simply as I could make it.
actor Adder {
private var sum: Int = 0
public func addOne() async {
var s = await getSum()
s = s+1
await setSum(to: s)
}
private func getSum() async -> Int {
await pause()
return sum
}
private func setSum(to newSum: Int) async {
await pause()
sum = newSum
}
private func pause() async {
let pause = Int.random(in: 0..<100)
try! await Task.sleep(for: .milliseconds(pause))
}
public func printSum() {
print("Sum is \(sum)")
}
}
// Sum to ten in parallel
let adder = Adder()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<10 {
group.addTask {
await adder.addOne()
}
}
await group.waitForAll()
}
await adder.printSum()
This actor just adds one to a sum on each call to addOne
. I have made addOne
include a few await
s to introduce a race.
My guess is that most newcomers to actors would actually think this code is completely safe. They would assume a call to addOne
will be atomic — it will finish completely before the next call to addOne
is handled. That isn't the case, as demonstrated when you try to run the code. The sum is always wrong in my tests.
This is a contrived and simple example, and could easily be rewritten to work properly, but real world examples are generally much more complex. It is a shame that actors can't deliver on the promise to protect shared data, and make it easy to reason about. Reasoning about a somewhat complex actor, where you have to account for interlacing of methods, is just as difficult as reasoning about multithreaded code.
This isn't intended as a rant. I have brought it up because I think the concept of actors is a good one. If they could deliver on the promise of making it easy to control access to data and reason about concurrency, it would be a great step forward.
What could we do about this? I would like a mechanism whereby a function like the sync
one mentioned above, could be marked as atomic
. Such a function would need to run to completion before a new call from outside the actor could be handled. Effectively, there would be an extra top level queuing or locking mechanism in actors, which would only allow one atomic
function to be in flight at one time, and when an atomic func was running, all calls from outside the actor would be queued up.
I realize this introduces other problems, like the potential for deadlocks. I think deadlocks could be avoided with adequate restrictions on these atomic functions, or adequate run time checks, but I am a user of Swift, and not a compiler developer.
What do others think? Is there a problem? And is there a good solution?