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.

Two CPU timelines of 40 ticks for tasks A, B and C. Preemptive: B runs 4 ticks at a time and timer interrupts hand the CPU to C and A. Cooperative: A, B and C take turns at their yield calls until tick 13, then B runs an endless loop for the rest of the timeline and A and C never run again
The same tasks, and B gets stuck in an endless loop: the timer still shares the CPU fairly, but without a timer nobody can stop B.

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

PreemptiveCooperative
Who decides to switchthe kernel, on a timer interruptthe running task, at yield() / await / I/O
A task that never yieldsgets its share, the others keep runningeveryone else starves (Demo: a runaway loop)
Wait of an interactive taskat most about one quantum per ready task (Demo: editor latency: 5 ticks)as long as the longest stretch between two yields (14 ticks)
Context switchesmore, every quantum (Demo: batch jobs: 12)fewer, only when asked (3), so batch work finishes sooner (tick 27 vs 36)
Shared dataa task can be stopped between any two instructions: needs locks or atomicscode between two yields runs without interruption on one thread
Used byoperating systems (Linux, Windows, macOS), Go since 1.14, the JVM's threadsevent 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.

Two columns, time going down. Preemptive with quantum 2: A loads 0 and adds 1, a timer interrupt switches to B, B loads 0 and adds 1, later A stores 1 and B stores 1, so counter is 1. Cooperative: A loads, adds and stores 1, yields, then B loads 1, adds and stores 2, so counter is 2
Preemption can split counter++ between two tasks, so one +1 is lost; with yield() only between whole updates, both count.

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.