[Pitch #2] Actors

Proposal Questions

  1. In "Actor Reentrancy" it's stated:

Generally speaking, the easiest way to avoid breaking invariants across an await is to encapsulate state updates in synchronous actor functions. Effectively, synchronous code in an actor provides a critical section, whereas an await interrupts a critical section. For our example above, we could effect this change by separating "opinion formation" from "telling a friend your opinion". Indeed, telling your friend your opinion might reasonably cause you to change your opinion!

Could an example be provided of "encapsulate[ing] state updates in synchronous actor function"? How is this any different than the already synchronous state updates taking place in the example?

  1. In "Deadlocks with non-reentrant actors":

Deadlocked actors would be sitting around as inactive zombies forever. Some runtimes solve deadlocks like this by making every single actor call have a timeout (such timeouts are already useful for distributed actor systems). This would mean that each await could potentially throw , and that either timeouts or deadlock detection would have to always be enabled. We feel this would be prohibitively expensive, because we envision actors being used in the vast majority of concurrent Swift applications. It would also muddy the waters with respect to cancellation, which is intentionally designed to be explicit and cooperative. Therefore, we feel that the approach of automatically cancelling on deadlocks does not fit well with the direction of Swift Concurrency.

Should we take this to mean Swift Concurrency will have no solution for deadlock detection or mitigation? Are there any solutions for developers to detect or otherwise avoid them? Or will this be the domain of tools outside the language like the Thread Sanitizer?

  1. In "Unnecessary blocking with non-reentrant actors":

With a reentrant actor, multiple clients can fetch images independently, so that (say) they can all be at different stages of downloading and decoding an image. The serialized execution of partial tasks on the actor ensures that the cache itself can never get corrupted. At worst, two clients might ask for the same image URL at the same time, in which there will be some redundant work.

As someone with a keen interest in adopting Swift Concurrency for networking, I can tell you it's key to have a solution for avoiding redundant work like network requests and image processing. This is especially true for libraries like AlamofireImage which offer extensible image processing pipelines for downloaded images where it's important to avoid reprocessing large images through CoreImage and other systems. Does (or will, in other proposals) Swift Concurrency offer any solution for avoiding these issues? (This may also relate to my later questions.)

General Design Questions

Actors' ability to verify thread safety will be extremely valuable to the community. Combined with async / await, it should greatly simplify the encapsulation of shared mutable state. I look forward (:grimacing:) to rewriting Alamofire yet again with this vastly simpler model. I spent quite a lot of time in Alamofire 5 getting synchronous properties and methods to play well with internally asynchronous actions while maintaining safe mutation and serial execution on shared DispatchQueues.

That said, I'm extremely wary of the async requirement for all outside actor access, especially without async properties or subscripts (yes, it's being pitched). This both makes actors less useful in non-async contexts and, perhaps more importantly, prevents developers from designing their ideal APIs. That is, it prevents them from offering synchronous APIs and properties even if they're perfectly safe.

More concretely, Alamofire's current design seems like an ideal application of actors. Requests are modeled as subclasses of the Request classes, largely mirroring the hierarchy of URLSessionTask (Request -> DataRequest -> UploadRequest, Request -> DownloadRequest, Request -> DataStreamRequest, and (eventually) Request -> WebSocketRequest). Request encapsulates all of the shared state, including mutable state, as well as internal API common to all Requests. Each subclass maintains whatever additional mutable state is necessary, and all mutable state that has public API is protected by a wrapper using os_unfair_lock. State that isn't expected to be publicly accessed is unprotected but safe access is ensured by the fact that all intercommunicating classes share an underlying serial DispatchQueue. So Requests communicate back to their originating Session, and vice versa, without locks.

Part of the API of Request I'm most concerned about are the various synchronous APIs for state changes and access to the various bits of state itself. For instance, like URLSessionTask, Request exposes resume(), suspend(), and cancel() methods which operate synchronously. Actions like adding a response handler are also synchronous and return the Request instance, allowing users to attach multiple handlers. Additionally, various bits of the Request's state is available publicly, like all of the URLRequests or URLSessionTasks its performed. So while the actions makes sense asynchronously, access to state properties really don't. Of course we could just expose requests() instead of requests, and maybe we'll get async read-only properties, but this seems to be exposing implementation details and deviates from typical Swift design. So it leads to a basic question.

How pervasive are async contexts intended to be? That is, what is the UX impact of having to make every method (and maybe property) async when using actors? Will there be a default, global actor that everything starts on and allows global awaiting?

Relatedly, a large amount of the API I've mentioned doesn't need to be async by definition and would only have the requirement due to being an actor. This seems to expose unnecessary underlying implementation details, just like it would if I exposed the various state properties or handler methods using completion handlers rather than making them externally synchronous while encapsulating internal locking or async work. So it certainly seems like this proposal will make a lot of APIs async when they don't otherwise need to be. That is, they could be safely exposed synchronously if only it was made possible. Will such capabilities be exposed for actors? If not, while I disagree with those who say try or await are overused, it will certainly be the case that async will suddenly become far more widespread than seems necessary, leading to a proliferation of await even for simple APIs.

Finally, there was nothing in the proposal about the intended performance or other system impact of actors. Users often want to enqueue perhaps thousands of asynchronous actions, so what are the limitations of actors in that scenario? In Alamofire 5 we took great pains to move away from the previous "one DispatchQueue per Request" model to a shared, serial DispatchQueue that underlies all Requests made by the same Session, eliminating one major bottleneck for users wanting to enqueue thousands of network requests at the same time (it's still not a good idea). It would be a shame for actors to reintroduce such limitations.

2 Likes