Academy
ProgrammingPython AdvancedConcurrency & asyncio

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: threading and multiprocessing are 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 and asyncio syntax 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 def defines a coroutine; await pauses it and yields control to the event loop
  • asyncio.run(main()) starts the event loop and runs a coroutine
  • asyncio.gather(*coroutines) runs multiple coroutines concurrently and collects results
  • asyncio.create_task schedules a coroutine to run in the background
  • asyncio.wait_for(coro, timeout) raises TimeoutError if the coroutine takes too long
  • async for consumes async generators
  • asyncio is for I/O-bound work; multiprocessing is 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.