Concurrency & asyncio
The Problem: Waiting Is Wasteful
Most programs spend a surprising amount of time doing nothing — waiting for a file to load, waiting for a network response, waiting for user input. While a program waits, the CPU is idle. That's wasted capacity.
Concurrency is the ability to handle multiple tasks whose progress overlaps in time. This doesn't necessarily mean doing two things simultaneously — it means making progress on one task while another is paused waiting for something.
Python has three concurrency models: threading, multiprocessing, and asyncio. This lesson focuses on asyncio — the model best suited to I/O-bound tasks, and the one that runs correctly in this browser-based sandbox.
Note for desktop Python:
threadingandmultiprocessingare also available and powerful for CPU-bound work. The sandbox runs on Pyodide in a single browser thread, so asyncio is the correct model here. The concepts andasynciosyntax are identical between the sandbox and a real Python installation.
Synchronous vs Asynchronous
The difference in one example:
Synchronous — tasks run one after another:
Start task A → wait → finish A → start task B → wait → finish B
Total time: wait_A + wait_B
Asynchronous — while one task waits, another runs:
Start task A → (A waits) → start task B → (B waits) → A finishes → B finishes
Total time: max(wait_A, wait_B)
When tasks spend most of their time waiting — network requests, file I/O, database queries — the asynchronous model can be dramatically faster.
async and await
Python's asyncio uses two keywords: async def defines a coroutine (an asynchronous function), and await pauses the coroutine until the awaited task completes — yielding control back to the event loop so other coroutines can run.
This runs sequentially — greet("Priya") finishes before greet("Arjun") starts. Total time: ~2 seconds. await asyncio.sleep(delay) simulates waiting (like a network call) without blocking.
Now run them concurrently:
asyncio.gather() runs multiple coroutines concurrently. Both greetings complete in ~1 second — not 2. While one coroutine waits, the other runs.
How the Event Loop Works
asyncio runs an event loop — a scheduler that manages coroutines. When a coroutine hits await, it pauses and hands control back to the event loop. The loop checks which other coroutines are ready to resume, runs them, and returns to the paused one when its wait is over.
asyncio.run(main()) starts the event loop, runs main() to completion, then shuts the loop down. This is always how you enter asyncio from regular (synchronous) code.
A Realistic Example: Simulated API Calls
Three simulated API calls that would take 3.3 seconds sequentially. Running concurrently, they finish in ~1.5 seconds — the duration of the slowest one. asyncio.gather collects all return values in a list, preserving the original order.
asyncio.create_task — Fire and Forget
gather waits for everything before continuing. Sometimes you want to start a task and do other work while it runs:
create_task schedules a coroutine to run without waiting for it immediately. The tasks run in the background while main continues. await task1 waits for a specific task to finish. This is the pattern for background work — pre-fetching data, warming caches, writing logs.
Timeouts
Waiting indefinitely for a slow task is rarely acceptable. asyncio.wait_for adds a timeout:
After 1.5 seconds, asyncio.TimeoutError is raised and caught. The event loop cancels the slow coroutine automatically.
Async Generators
Generators can be asynchronous too — yielding values at intervals with await between yields:
async for consumes an async generator, waiting for each yield. The pattern is identical to regular generators but participates in the event loop — allowing other coroutines to run between yields.
When asyncio Is and Isn't the Answer
asyncio excels at I/O-bound concurrency — many tasks that mostly wait: web scraping, API calls, database queries, file operations, WebSocket connections.
It is not the right tool for CPU-bound work — tasks that compute continuously without waiting (image processing, numerical simulations, machine learning training). For those, multiprocessing is the correct choice — it uses separate processes that truly run in parallel across multiple CPU cores.
The rule: if your tasks wait for external resources, use asyncio. If they compute heavily, use multiprocessing.
What You Have Learned
asyncio enables Python to handle many concurrent tasks efficiently by pausing at natural waiting points and resuming when ready.
The key ideas:
async defdefines a coroutine;awaitpauses it and yields control to the event loopasyncio.run(main())starts the event loop and runs a coroutineasyncio.gather(*coroutines)runs multiple coroutines concurrently and collects resultsasyncio.create_taskschedules a coroutine to run in the backgroundasyncio.wait_for(coro, timeout)raisesTimeoutErrorif the coroutine takes too longasync forconsumes async generators- asyncio is for I/O-bound work;
multiprocessingis for CPU-bound work
In the next lesson, you'll enter the world of scientific Python — NumPy, the foundation of numerical computing, data science, and machine learning in Python.