Here's how Swift uses your hardware to guarantee actor isolation 👷
When you run work on a Swift actor, it doesn't execute immediately: it has to be drained onto the co-operative thread pool.
Actors are backed by a private internal linked list of incoming jobs. The scheduleActorProcessJob function in the runtime creates a ProcessOutOfLineJob to “drain” the actor, where the jobs in the linked list are dequeued, prioritised, and executed. (screenshot #1)
swift_task_enqueueGlobal is the ultimate runtime call which hands the job off to the global concurrent executor, where the platform’s cooperative thread pool places them on a thread. On Apple platforms, this is libdispatch.
ProcessOutOfLineJob is a small object allocated separately from the default actor. It registers the process method, which is invoked as the entry-point of job execution. It’s called when the job begins running on a worker thread in the cooperative thread pool. (screenshot #2)
This process method drains the job queue in the default actor. (screenshot #3)
Remember that that the default actor contains two queues: the “incoming” queue, implemented by a linked list, and the “executing” priority queue, where they are bucketed by priority and executed.
defaultActorDrain is where the linked list of incoming jobs is processed into this priority queue, and jobs are finally executed one-by-one. (screenshot #4)
To drain the queue safely, atomic CAS is used to place a lock on the default actor by setting the state machine to running. Then, in a loop, a job is taken from the existing priority queue (if any exists) and executed on the executor context, through runJobInEstablishedExecutorContext.
At the end of the loop, processIncomingQueue places any jobs from the linked list of incoming jobs into the priority queue. The drain continues, in a loop, until all the queued work is complete. When no incoming jobs remain, and they’ve all been executed, the actor unlocks and the loop completes.
Recall that process was scheduled on the global concurrent executor.
This drainer job is tail-called at the end of the process function, and executes all the jobs in this context via runJobInEstablishedExecutorContext. This means the work queued on the actor is all performed serially on the same cooperative thread pool thread, until the drain is finished.
I literally went all the way through the source code to understand how actors actually work. It was exactly as fun as it sounds. 🕴️
blog.jacobstechtavern.com/p/…