How Python's asyncio Actually Schedules Coroutines
A Python coroutine doesn't run until something explicitly awaits it -- and once it does run, it keeps control of the single thread until it deliberately hands it back.
The existing pieces on Node.js's event loop and JavaScript's single-threaded async model in this category cover the general shape of cooperative, single-threaded concurrency. Python's asyncio implements that same general idea with its own specific mechanics, built around two core pieces: coroutines, defined with async def, and an event loop that decides which coroutine actually gets to run at any given moment.
Calling a function defined with async def doesn't actually run its code -- it immediately returns a coroutine object, a paused, not-yet-started unit of work. That coroutine only actually begins executing once something awaits it directly, or it's explicitly wrapped in a Task and handed to the event loop to run -- a distinction that trips up plenty of Python developers who write an async function call, forget the await, and get a coroutine object back instead of the result they expected, often without an obvious error.
Once a coroutine does start running, it isn't interrupted at arbitrary points the way a pre-emptively scheduled thread can be -- it keeps control of the single thread running the event loop until it reaches an await on something that genuinely suspends, like a network call or asyncio.sleep(). Everything between one await and the next runs as one uninterrupted block, which is why a long stretch of CPU-bound work with no await inside it can block the entire event loop, stalling every other coroutine waiting their turn, even though the code looks "async."
The event loop's actual job is tracking every suspended coroutine and what each one is waiting on, and the moment whatever it was waiting for completes -- data arrives, a timer elapses -- resuming exactly that coroutine from precisely the line it paused on, then picking whichever other ready coroutine runs next once the current one either finishes or hits its own next suspending await. It's a single thread juggling many paused, resumable functions, never actually doing more than one thing at the exact same instant.
The practical rule that follows directly from this: asyncio genuinely helps when a program spends most of its time waiting on I/O, since that waiting is exactly what lets the event loop switch to other work -- it does nothing to speed up CPU-bound work with no natural suspension points, which needs actual parallelism (multiple threads or processes) instead, a distinction worth checking before reaching for asyncio as a general performance fix.
