Everything so far —
The first and most consequential choice is the shape of the run queue: one global queue shared by all cores, or one run queue per CPU. It sounds like a minor implementation detail. It is the whole ball game.
A single global queue (SQMS — single-queue multiprocessor scheduling) is trivially fair
and load-balanced: every idle core just grabs the next task off the one shared list. But that list is shared
mutable state, so every scheduling decision on every core must take the same lock. With
So real kernels — Linux, Windows, FreeBSD — use per-CPU run queues (MQMS). Each core has its own queue and its own lock, so scheduling decisions are almost entirely local and contention-free, and a task tends to stay on one core and keep its cache warm. The price is that the queues drift out of balance — one core piled high while another sits idle — which the kernel must actively correct by migrating tasks. The diagram shows the layout and the migration that rebalances it.
Balancing happens two ways. In push migration a periodic balancer notices CPU 0 is overloaded and shoves a task onto idle CPU 2. In pull migration — Linux calls it work stealing — an about-to-go-idle core reaches into a busier core's queue and takes work for itself. Both fix imbalance, but both pay the migration cost: the task arrives on its new core with a cold cache and must refetch its working set from a shared cache or memory. A migration can cost thousands of cycles of cache misses, so the balancer is deliberately reluctant — it migrates only when the imbalance is worth the reload, and it respects affinity by preferring to keep tasks where their data already is.
Here is the essence of work stealing: an idle core finds the busiest core and pulls half its backlog. Halving (rather than taking one task) is what real stealers do — it minimises how often you have to steal, amortising the migration cost.
Model it simply. With per-CPU queues, adding a core adds a core's worth of scheduling throughput — the line
is straight,
Meet gang scheduling's reason for existing. A parallel job whose threads constantly communicate — barrier synchronisation, a producer/consumer pipeline, an MPI collective — grinds to a halt if even one of its threads is descheduled while the others try to talk to it. Thread A spins waiting on a message from thread B, but B is not currently on any core, so A wastes its entire quantum spinning. With hundreds of such micro-stalls a second the job crawls even though CPUs sit idle. Gang scheduling co-schedules all the job's threads in the same time slice across cores, so they are all present to talk to each other. HPC schedulers and hypervisors (which must avoid descheduling one vCPU of a guest that holds a spinlock — "lock-holder preemption") care about this intensely.
It is tempting to balance queues to exactly equal length at every tick. That is a trap. Each migration costs a cold-cache reload (and, across NUMA nodes, remote-memory penalties on every subsequent access until the pages migrate too). A scheduler that chases perfect balance migrates constantly and spends more on cache misses than it saves in idle time. Real balancers are hysteretic and lazy: they tolerate mild imbalance, migrate only when a core would otherwise idle or the imbalance crosses a threshold, and prefer moving a task that is not "cache-hot". The right objective is keep every core usefully busy at the least migration cost, not equal queue lengths.
Multiprocessor scheduling layers a placement problem on top of the single-core ordering
problem, and every knob trades the same two currencies: locality (keep a task's data close
and its cache warm) versus utilisation (keep every core busy). Linux's CFS/EEVDF runs a
per-CPU red-black tree with a hierarchical, NUMA-aware balancer that walks scheduling domains
(SMT siblings, then a socket, then across sockets), migrating reluctantly and locally first. The last
scheduling frontier is tasks with deadlines rather than mere fairness —