The idea: who takes the CPU back?
One CPU runs one task at a time, so something must decide when the running task stops and the next one starts. A preemptive scheduler takes the CPU back: a hardware timer interrupt fires when the task has used its quantum (time slice), and the kernel switches to the next ready task. A cooperative scheduler never takes it back: the task keeps the CPU until it gives it up by calling yield(), waiting for I/O or ending. The page runs the same three tasks both ways, side by side, one tick per step.
Preemptive scheduling
The kernel programs a timer. When it fires, the CPU jumps into the kernel's interrupt handler, whatever the task was doing. If the task has used up its quantum and another task is ready, the kernel saves the task's registers, puts it at the back of the ready queue and loads the next task: a context switch. The task never notices, and no task can keep the CPU from the others. This is how Linux, Windows and macOS schedule threads; real quanta are a few milliseconds. In the left lane the timer shows how many ticks of the quantum are used.
Cooperative scheduling
There is no timer. A task runs until it calls yield(), starts an I/O and blocks, or ends; then the scheduler picks the next ready task. Switching happens only at points the programmer chose, so it is cheap and predictable, but every task must yield often enough. Windows 3.x and the classic Mac OS worked this way, and one badly written program could freeze the whole machine. Today it is the model inside a single thread: the browser and Node.js event loop, Python asyncio, and coroutines all switch only at await or yield.
When to pick which
| Preemptive | Cooperative | |
|---|---|---|
| Who decides to switch | the kernel, on a timer interrupt | the running task, at yield() / await / I/O |
| A task that never yields | gets its share, the others keep running | everyone else starves (Demo: a runaway loop) |
| Wait of an interactive task | at most about one quantum per ready task (Demo: editor latency: 5 ticks) | as long as the longest stretch between two yields (14 ticks) |
| Context switches | more, every quantum (Demo: batch jobs: 12) | fewer, only when asked (3), so batch work finishes sooner (tick 27 vs 36) |
| Shared data | a task can be stopped between any two instructions: needs locks or atomics | code between two yields runs without interruption on one thread |
| Used by | operating systems (Linux, Windows, macOS), Go since 1.14, the JVM's threads | event loops, asyncio, coroutines, many embedded and real-time loops |
With fair tasks the two give the same timeline (Demo: fair tasks: 9 switches each, all done at tick 33): every task yields after 3 ticks, before its 4-tick quantum is up, so the timer never has to step in. The quantum is a trade-off of its own: shorter means lower waits but more switches; longer means fewer switches but behaves more and more like cooperative scheduling.
The race between two yields
counter++ is three machine instructions: load the value into a register, add 1, store it back. A timer interrupt can land between the load and the store. Another task then reads the same old value, and one of the two stores overwrites the other: a lost update (Demo: shared counter race: counter = 2 instead of 4). Under cooperative scheduling the task is never stopped between two yields, so the same code is correct without a lock.
That safety holds only on one thread: an event loop running on several cores, or tasks sharing memory with other threads, need locks again. See Mutex and compare-and-swap.
What to try
- Demo: a runaway loop, and watch the cooperative CPU box stay on B while A and C turn yellow (waiting) for the rest of the timeline.
- Demo: editor latency: the editor A wakes up after each key press (blue = blocked on I/O) and waits in yellow until it runs.
- Pick 3 batch jobs and try quantum 2, 4 and 6: the preemptive switch count drops and its timeline turns into the cooperative one.
- Pick shared counter++ with quantum 3 or 4: whether updates are lost depends on where the timer lands.
See also CPU scheduling (FCFS, SJF, round robin and CFS on the same processes), context switch (what saving and loading registers really involves), browser event loop and coroutines.
Real-time priorities (SCHED_FIFO, SCHED_RR) and priority inversion are on Real-Time Scheduling and Priority Inversion.
What the page leaves out
A context switch costs a whole tick here, which exaggerates it: a real switch takes microseconds against a quantum of milliseconds, although cold caches and TLB entries cost more afterwards. Real kernels are not plain round robin: Linux (CFS, and EEVDF since 6.6) picks the task that has run least, gives each a slice that depends on how many are ready, and lets a task that wakes up from I/O preempt the running one at once, so interactive tasks wait even less than here. The page also leaves out priorities, several CPUs and load balancing, kernel preemption (whether kernel code itself can be interrupted), signal-based preemption of goroutines and other user-space threads, and how an event loop turns blocking I/O into callbacks.