cooperative scheduling and the yield point
Imagine a meeting where there is no chairperson with a gavel. People take turns to speak only because each speaker, of their own accord, eventually pauses and says 'I'm done, who's next?'. It works beautifully — as long as nobody hogs the floor. Cooperative scheduling is exactly this: tasks run until they voluntarily give up the thread at a yield point, and only then does the scheduler hand control to another task. Nobody is forcibly interrupted.
Contrast it with the OS thread scheduler, which is preemptive: a timer interrupt fires, the kernel snatches the CPU from a thread mid-instruction and gives it to another, whether the thread likes it or not. Cooperative scheduling has no such timer; the scheduler only gets to choose a new task when the current one yields. In async code, the yield points are the await points: each await is a spot where the coroutine may suspend and return control to the executor. Between two awaits, the task runs uninterrupted — it owns the worker thread completely. So 'yielding' is not magic; it is simply the task reaching an await whose result is not yet ready (or an explicit yield_now()), saving its state, and returning to the executor so another ready task can run.
The appeal is efficiency and simplicity: switching between cooperative tasks is just a function return and a re-poll — no kernel involvement, no register save of a full thread, no cache-cold context switch. That is why one thread can cooperatively multiplex thousands of async tasks far more cheaply than the OS multiplexes threads. The price, and the danger, is the flip side of 'nobody is forcibly interrupted': a task that never reaches a yield point — a tight CPU loop with no await — holds the worker thread forever and starves every other task on it. Cooperative scheduling is only fair if every task cooperates. This is the deep reason behind the don't-block-the-executor rule.
Between awaits, a task owns the thread: loop { let x = recv().await; /* yield point */ process(x); /* runs uninterrupted until next .await */ } A long process() with no .await blocks every co-located task.
Control only switches at await points; the scheduler cannot preempt a running cooperative task.
Cooperative does not mean lower latency for everyone — one greedy task with no yield point can monopolise a worker. Some runtimes (Go, newer Tokio) add limited preemption or auto-yield to soften this, but the core model still relies on cooperation.