Async Programming
Asynchronous programming lets a single thread juggle many waiting tasks by cooperatively switching between them, rather than blocking on one at a time or spawning parallel workers.
Async programming is a style of writing concurrent code where a single thread of execution voluntarily switches between multiple in-progress tasks, running each one until it needs to wait (typically for I/O), then moving on to another task rather than blocking. This gives you the illusion of doing many things 'at once' without the overhead of extra threads or processes, as long as the work involves waiting rather than heavy computation.
Concurrency Versus Parallelism
Concurrency and parallelism are related but distinct ideas. Concurrency means structuring a program to handle multiple tasks that are in progress during overlapping time periods -- it's about dealing with many things, not necessarily doing many things at the exact same instant. Parallelism means literally executing multiple tasks at the same instant, which requires multiple CPU cores actually running code simultaneously.
Async programming (via asyncio and async/await) provides concurrency, not parallelism: it runs on a single thread, so at any given instant only one piece of code is actually executing. What it provides is the ability to have many tasks 'in flight' -- some waiting on I/O, one actively running -- without needing separate threads or processes. This makes it well suited to I/O-bound work (many network requests, file operations, or timers pending at once) and poorly suited to CPU-bound work (heavy computation, which would still block the single thread; use multiprocessing for that, see the related topic).
Concurrency (async, single thread): tasks interleave, take turns
Task A: ----wait----[run]--wait--
Task B: --[run]---wait------[run]
(only one truly executing at any instant, but both make progress)
Parallelism (multiprocessing, multiple cores): tasks truly overlap
Task A: --------[run][run][run]---------
Task B: --------[run][run][run]---------
(both executing at the exact same instant, on different cores)Cooperative Scheduling
Async concurrency in Python is cooperative: a running piece of code must explicitly yield control back (via await) at points where it would otherwise wait, rather than being forcibly interrupted by the runtime at arbitrary points, as OS thread scheduling does (preemptive scheduling). This means a coroutine that never awaits anything -- e.g. one that runs a long, blocking, CPU-bound loop -- will hog the single thread and starve every other task, because nothing forces it to yield.
This is a core mental shift from threads: with threads, the OS can pause any thread at nearly any point to run another; with async, YOUR code decides when it's safe to pause (at an await), and the runtime trusts that decision.
Event Loop
The event loop is the engine that makes cooperative scheduling work: a loop that keeps track of pending tasks, runs whichever one is ready to make progress, and switches to another when the current one yields control (awaits something not yet ready, like a network response). It's conceptually similar to a restaurant host managing several parties: seat one, deal with their order, then check on another party while the first one is still deciding, rather than a single waiter standing frozen at one table until they're completely done.
In Python, the event loop is provided by the asyncio module (see the dedicated asyncio topic for its API in depth). Broadly: you don't call coroutine functions directly and expect them to run; you hand them to the event loop (e.g. via asyncio.run()), and the loop decides when each piece of code actually executes, based on what's ready.
Coroutine
A coroutine is a special kind of function, defined with async def, that can be paused and resumed. Calling an async def function does not run its body immediately -- it returns a coroutine object, a kind of paused, resumable computation that must be driven forward, typically by awaiting it or scheduling it on the event loop.
greet("Ada") alone would just create a coroutine object without running its body; awaiting it (or scheduling it as a Task) is what actually drives execution.
import asyncio
async def greet(name: str) -> str:
await asyncio.sleep(1) # simulate waiting (e.g. for I/O)
return f"Hello, {name}!"
async def main() -> None:
result = await greet("Ada") # runs the coroutine, waits for its result
print(result)
asyncio.run(main()) # "Hello, Ada!" after about 1 secondAwaitable
'Awaitable' is the general term for anything that can appear after await: coroutine objects, Task objects, and Future objects (see the asyncio topic for the distinctions between these). Formally, an object is awaitable if it implements the __await__ method, which the async runtime uses to drive it forward step by step, pausing at each point it isn't yet ready to continue.
You can only use await inside an async def function -- attempting to await something at the top level of ordinary synchronous code is a SyntaxError (outside of specific contexts like the interactive REPL/notebooks, which special-case it).
Why Async Matters
For programs that spend most of their time waiting -- a web server handling many simultaneous slow client connections, a script making thousands of HTTP requests, a chat application waiting on multiple socket connections -- async programming lets a single thread handle very large numbers of concurrent, waiting operations with far less memory and scheduling overhead than one OS thread per task would require. Threads are relatively heavyweight (each has its own stack, and the OS scheduler has more entities to juggle); coroutines are cheap, lightweight objects that the async runtime manages far more efficiently at scale (tens of thousands of concurrent coroutines are practical; tens of thousands of OS threads generally are not).
Basic Syntax Preview
Both coroutines are in flight concurrently; total time is roughly max(2, 1) = 2 seconds, not 2 + 1 = 3 seconds, because they overlap while waiting.
import asyncio
async def fetch_data(delay: float, label: str) -> str:
print(f"{label}: starting")
await asyncio.sleep(delay) # simulate an I/O wait
print(f"{label}: done")
return f"{label} result"
async def main() -> None:
# Run two "fetches" concurrently rather than one after another
results = await asyncio.gather(
fetch_data(2, "A"),
fetch_data(1, "B"),
)
print(results)
asyncio.run(main())
# B starts, A starts, B finishes first (~1s), A finishes (~2s total, not 3s)Edge Cases and Common Pitfalls
A coroutine created but never awaited (and never scheduled as a Task) simply never runs its body -- Python will typically emit a RuntimeWarning: coroutine '...' was never awaited. Mixing blocking, synchronous calls (like time.sleep() or a blocking network library call) inside an async def function defeats the purpose: that call blocks the entire event loop, freezing every other concurrent task until it returns, because nothing yields control during a blocking call.
Practical Usage
Async programming is the right tool for I/O-bound concurrency at scale: web servers and clients (frameworks like the third-party FastAPI or Starlette build on asyncio), database access with async drivers, WebSocket handling, and any workload dominated by waiting on the network or disk rather than computing. It is the wrong tool for CPU-bound work (use multiprocessing/ProcessPoolExecutor) and adds real complexity (an entirely different function-coloring and API surface) that isn't worth it for simple, low-concurrency scripts.
Mental Model
Picture a single cook (the one thread) managing several dishes at once by working on whichever one currently needs active attention, and stepping away from a dish the moment it just needs to simmer unattended (that's an await point) to go stir another. The cook is never doing two things at the literal same instant -- but by never standing idle waiting for one pot to boil, far more dishes get finished overall than if the cook rigidly finished one dish start-to-finish before touching the next.
Quick Reference
Concurrency: many tasks in progress, interleaved (async's domain)
Parallelism: many tasks executing at the literal same instant (multiprocessing's domain)
Coroutine: an async def function's paused, resumable computation
Awaitable: anything usable after 'await' (coroutine, Task, Future)
Event loop: the scheduler that runs ready tasks and switches on await
Cooperative scheduling: code yields control voluntarily via awaitKey Takeaways
Async programming provides concurrency (interleaved progress on one thread), not parallelism (simultaneous execution across cores) -- it suits I/O-bound work, not CPU-bound work.
Scheduling is cooperative: a coroutine must explicitly yield via await; nothing preempts it, so a coroutine that never awaits blocks everything else.
The event loop drives coroutines forward, switching between them at await points based on what's ready to make progress.
Calling an async def function returns a coroutine object; the body doesn't run until that coroutine is awaited or scheduled.
'Awaitable' covers coroutines, Tasks, and Futures -- anything implementing __await__.
Blocking calls inside async code freeze the entire event loop and defeat the purpose of using async in the first place.
async def fetch():... fetch() # just creates a coroutine object -- body never runs # RuntimeWarning: coroutine 'fetch' was never awaited async def fetch():... async def main(): await fetch() # actually runs the coroutine's body asyncio.run(main())
Calling an async def function only creates a coroutine object; it must be awaited (or scheduled as a Task) for its body to actually execute.
import asyncio, time async def fetch(): time.sleep(2) # blocking call -- freezes the ENTIRE event loop return "done" import asyncio async def fetch(): await asyncio.sleep(2) # non-blocking wait -- yields control to other tasks return "done"
A blocking call inside an async function does not yield control back to the event loop, so every other concurrent task is stalled until it returns -- defeating the purpose of async.
async/await provides no parallelism for CPU-bound computation -- it's single-threaded concurrency for I/O waiting; heavy computation belongs in a process pool.
What is the key difference between concurrency and parallelism?
Concurrency means multiple tasks are in progress over overlapping time periods (interleaved, possibly on one thread); parallelism means multiple tasks execute at the literal same instant, which requires multiple cores actually running simultaneously.
Why is asyncio's scheduling called 'cooperative' rather than 'preemptive'?
Because a running coroutine must voluntarily yield control (via await) at points where it would wait; the runtime does not forcibly interrupt it at arbitrary points the way an OS scheduler preempts threads.
What happens when you call an async def function without awaiting it?
It returns a coroutine object without running its body at all; the body only executes once the coroutine is awaited or scheduled on the event loop, and Python typically warns 'coroutine was never awaited' if it's discarded unused.
What role does the event loop play in async programming?
It's the scheduler that tracks pending coroutines/tasks, runs whichever one is ready to make progress, and switches to another whenever the current one yields control at an await point.
Why is async programming well suited to I/O-bound work but poorly suited to CPU-bound work?
I/O-bound work spends most of its time waiting, during which a coroutine can yield and let others run, achieving high concurrency cheaply; CPU-bound work keeps the single thread continuously busy computing, so nothing else can run and there's no parallel speedup.
What does 'awaitable' mean, and what are the main kinds?
Anything that can appear after await -- objects implementing __await__ -- primarily coroutine objects, Task objects, and Future objects.
Why does calling a blocking function like time.sleep() inside async code defeat the purpose of using asyncio?
A blocking call does not yield control back to the event loop, so it freezes the entire single-threaded event loop -- every other pending coroutine is stalled until the blocking call returns, eliminating the concurrency asyncio is meant to provide.
Write two coroutines that each simulate waiting, and run them concurrently, printing total elapsed time.
import asyncio import time async def task(name: str, delay: float) -> None: print(f"{name} starting") await asyncio.sleep(delay) print(f"{name} finished") async def main() -> None: start = time.perf_counter() await asyncio.gather(task("X", 1.5), task("Y", 1.0)) print(f"elapsed: {time.perf_counter() - start:.1f}s") # ~1.5s, not 2.5s asyncio.run(main())
Both coroutines run concurrently on the single event loop thread; since they overlap while waiting, total elapsed time is close to the longer delay (1.5s) rather than the sum of both delays (2.5s).
What does this print, and why doesn't it print 42?
It prints something like <class 'coroutine'> -- calling compute() does not execute its body; it only creates a coroutine object. The body (including the print("computing") and the return) only runs once that coroutine is awaited or otherwise driven by the event loop, which never happens here.
Explain, with a short code sketch, why a coroutine containing a tight CPU-bound loop with no await starves other concurrent tasks.
import asyncio async def hog(): total = 0 for i in range(10**8): # no await anywhere in this loop total += i return total async def ticker(): for i in range(5): print(f"tick {i}") await asyncio.sleep(0.2) async def main(): await asyncio.gather(hog(), ticker()) # ticker's prints are all delayed until hog() finishes, # because hog() never yields control back to the event loop asyncio.run(main())
Because hog() never reaches an await point during its loop, it never yields control back to the event loop; the loop cannot switch to ticker() until hog() finally returns, so all of ticker()'s prints are delayed and bunch up at the end instead of appearing every 0.2s as intended.
async def compute():
print("computing")
return 42
result = compute()
print(type(result))