Python Cooperative Scheduling — Core Concepts
Cooperative vs preemptive scheduling
Operating systems use preemptive scheduling: the kernel interrupts running processes at any time to give other processes a turn. The process has no say in when it’s paused.
Python’s asyncio uses cooperative scheduling: each coroutine runs until it explicitly yields control via await. The event loop never interrupts a running coroutine. The coroutine decides when to pause.
| Preemptive | Cooperative |
|---|---|
| OS threads, processes | asyncio, generators, trio |
| Kernel interrupts at any time | Task yields at await |
| Needs locks for shared data | Shared data safe between awaits |
| Can’t hog the CPU (kernel prevents it) | A blocked task freezes everything |
| Complex race conditions | Simpler reasoning about state |
How the event loop works
The asyncio event loop is a single-threaded scheduler:
- Pick the next ready task from the queue
- Run it until it hits
await - The
awaitregisters a callback (I/O ready, timer expired, etc.) - Return to step 1
- When a callback fires, its task becomes ready again
Between any two await points, your code has exclusive access to all shared state. This is why asyncio programs rarely need locks — atomicity comes from the cooperative design.
The yield points
Every await is a potential context switch. Between awaits, no other task can run:
async def transfer(source, dest, amount):
# No other task runs during these lines
balance = source.balance
source.balance = balance - amount
dest.balance += amount
# ^ All three operations are "atomic" because there's no await
Compare with the dangerous version:
async def transfer_dangerous(source, dest, amount):
balance = source.balance
await some_io_operation() # ← another task could modify source.balance here!
source.balance = balance - amount
dest.balance += amount
The await creates a window where other tasks can run and modify shared state. This is the fundamental reasoning tool for async Python.
The starvation problem
If a coroutine does CPU-heavy work without awaiting, it blocks the entire event loop:
async def cpu_hog():
# This blocks ALL other tasks for the entire computation
result = sum(i * i for i in range(10_000_000))
return result
Solutions:
- Break work into chunks with periodic
await asyncio.sleep(0)to yield - Offload to a thread with
asyncio.to_thread()for CPU-bound work - Use a process pool for heavy computation
Cooperative scheduling beyond asyncio
Python has multiple cooperative scheduling systems:
- Generators (
yield) — the original cooperative multitasking in Python - asyncio (
await) — standardized async I/O scheduling - trio — structured concurrency with stricter yield guarantees
- greenlet/gevent — monkey-patched cooperative threading
All share the core principle: the running code decides when to give up control.
Common misconception
“Cooperative scheduling is slower than preemptive.” For I/O-bound workloads, cooperative scheduling is often faster because context switches only happen at meaningful points (I/O boundaries), not at arbitrary times. There’s no kernel overhead for switching, no TLB flushes, no register saves. An asyncio context switch costs roughly 1-5 microseconds; an OS thread switch costs 10-50 microseconds.
The one thing to remember: cooperative scheduling means tasks yield control voluntarily at await points. Between awaits, your code runs atomically. This makes async Python simpler to reason about than threaded code, but requires discipline to avoid blocking the event loop with CPU-intensive work.
See Also
- Python Actor Model Why treating each piece of your program like a person with their own mailbox makes concurrency way less scary.
- Python Aiocache Caching aiocache remembers expensive answers so your async Python app doesn't waste time asking the same question twice.
- Python Aiofiles Async Io aiofiles lets your async Python program read and write files without freezing — because normal file operations secretly block everything.
- Python Aiohttp Understand Aiohttp through an everyday analogy so Python behavior feels intuitive, not random.
- Python Anyio Portability AnyIO lets your async Python code work with any async library — write once, run on asyncio or Trio without changes.