AnyLearn
All lessons
Programmingadvanced

Async, Event Loops, and Futures

Threads are not the only model for concurrency. Learn how blocking vs non-blocking I/O works, how the event loop and reactor pattern scale to millions of connections, and how callbacks evolved into futures, promises, and async/await — plus where coroutines fit and when async loses to threads.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

Blocking vs non-blocking I/O: the fundamental split

When a thread calls read() on a socket, two things can happen:

  • Blocking I/O (default): the kernel suspends the thread until data arrives. The thread consumes a stack (~1–8 MB) and an OS scheduling slot while doing nothing.
  • Non-blocking I/O: the call returns immediately — either with data or with EAGAIN ("no data yet, try again"). The thread is free to do other work.

With blocking I/O, the standard model is one thread per connection. At 10 000 concurrent connections you have 10 000 threads, each burning stack memory and causing 10 000-way context-switching. This is the C10K problem (Kegel, 1999).

Non-blocking I/O breaks the 1:1 mapping: a single thread can manage thousands of connections by checking which ones have data ready and processing only those. The mechanism that tells you "these file descriptors are ready" is select, poll, epoll (Linux), or kqueue (BSD/macOS).

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. Blocking vs non-blocking I/O: the fundamental split

When a thread calls read() on a socket, two things can happen:

  • Blocking I/O (default): the kernel suspends the thread until data arrives. The thread consumes a stack (~1–8 MB) and an OS scheduling slot while doing nothing.
  • Non-blocking I/O: the call returns immediately — either with data or with EAGAIN ("no data yet, try again"). The thread is free to do other work.

With blocking I/O, the standard model is one thread per connection. At 10 000 concurrent connections you have 10 000 threads, each burning stack memory and causing 10 000-way context-switching. This is the C10K problem (Kegel, 1999).

Non-blocking I/O breaks the 1:1 mapping: a single thread can manage thousands of connections by checking which ones have data ready and processing only those. The mechanism that tells you "these file descriptors are ready" is select, poll, epoll (Linux), or kqueue (BSD/macOS).

2. The event loop and the reactor pattern

An event loop is a single loop that: (1) asks the OS which I/O is ready (epoll_wait), (2) dispatches the corresponding callbacks, (3) repeats. This is the reactor pattern:

while True:
    events = epoll.poll(timeout=None)    # blocks until at least one fd is ready
    for fd, event_type in events:
        handler = handlers[fd]
        handler(fd, event_type)           # synchronous callback — must be fast

Key properties:

  • Single-threaded (in the basic form). No mutexes needed — only one thing runs at a time.
  • All handlers must be non-blocking and fast. A slow callback freezes the entire loop and starves all other connections. This is the cardinal sin of event-loop programming.
  • I/O-bound work shines. Thousands of idle connections cost almost nothing; you only spend CPU on connections that have data.

Node.js, Nginx, Redis, Python asyncio, and libuv all use variants of this pattern.

3. Callbacks: the original async API

The first async APIs expressed continuation as a callback — a function passed as an argument, called when the async operation completes:

// Node.js callback-style
fs.readFile('config.json', 'utf8', (err, data) => {
    if (err) { handleError(err); return; }
    JSON.parse(data); // parse the file
    db.query('SELECT ...', (err2, rows) => {
        if (err2) { handleError(err2); return; }
        http.get(url, (err3, resp) => {
            // three levels deep — "callback hell"
        });
    });
});

Callbacks work, but nesting is painful and error handling is manual at every level. Sequential async operations require nesting, not sequencing. Exception propagation is impossible — errors must be passed as the first argument by convention (err-first callbacks in Node). This pushed the ecosystem toward higher-level abstractions.

4. Futures and promises: async as a value

A future (also called a promise in JavaScript) is an object that represents an async computation whose result will be available eventually. You chain continuations on it instead of nesting:

// Promise chain — same logic as the callback example
fs.promises.readFile('config.json', 'utf8')
  .then(data => JSON.parse(data))
  .then(cfg => db.query('SELECT ...', cfg))
  .then(rows => http.get(rows[0].url))
  .catch(err => handleError(err));  // one catch for the entire chain

Key improvements over callbacks:

  • Errors propagate automatically down the chain — one .catch() handles all.
  • Futures compose: Promise.all([p1, p2, p3]) waits for all three; Promise.race takes the first.
  • Futures can be returned from functions — callers decide what to do with the async value.

The mental model: a future is a box that starts empty and eventually contains a value (resolved) or an error (rejected). Once resolved, it never changes — futures are not mutable state.

5. async/await: linear syntax for async logic

async/await is syntactic sugar over futures that lets you write async code that reads like synchronous code:

import asyncio, aiohttp, aiofiles

async def fetch_and_save(url: str, path: str) -> None:
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            resp.raise_for_status()
            body = await resp.read()           # suspends here, yields to event loop
    async with aiofiles.open(path, 'wb') as f:
        await f.write(body)                    # suspends again
    print(f"Saved {len(body)} bytes to {path}")

async def main():
    # Fetch two URLs concurrently:
    await asyncio.gather(
        fetch_and_save('https://example.com/a', '/tmp/a'),
        fetch_and_save('https://example.com/b', '/tmp/b'),
    )

asyncio.run(main())

await suspends the current coroutine and returns control to the event loop, which runs other ready coroutines. When the awaited future resolves, the coroutine resumes from exactly where it left off.

6. Coroutines: cooperative scheduling under the hood

An async def function in Python (or a suspend fun in Kotlin, a generator-based coroutine in Go) is a coroutine: a function that can pause and resume at explicit suspension points (await, yield).

This is cooperative scheduling — the coroutine voluntarily yields at I/O boundaries. Contrast with preemptive scheduling (OS threads), where the kernel forcibly preempts a thread at any instruction:

Coroutines (cooperative)OS threads (preemptive)
Context switch cost~ns (no syscall)~µs (kernel mode switch)
Stack per task~few KB (heap-allocated frames)1–8 MB
Concurrent tasksMillionsThousands
Data sharing racesOnly at await pointsAny instruction
CPU parallelismNo (single-threaded loop)Yes

Coroutines eliminate data races within the event loop because no two coroutines run simultaneously — context switches happen only at await. This is why Node.js, despite being single-threaded, can serve thousands of concurrent requests without a single mutex.

7. Event loop: I/O-bound tasks interleaved

Two coroutines share one thread; each suspends at await, letting the other run while waiting for I/O.

sequenceDiagram
  participant L as Event Loop
  participant C1 as Coroutine A
  participant C2 as Coroutine B
  participant IO as OS / Network
  L->>C1: resume
  C1->>IO: send HTTP request (non-blocking)
  C1->>L: await response (suspend)
  L->>C2: resume
  C2->>IO: send DB query (non-blocking)
  C2->>L: await result (suspend)
  IO->>L: HTTP response ready
  L->>C1: resume with response
  IO->>L: DB result ready
  L->>C2: resume with result

8. When async beats threads — and when it doesn't

Async is the right tool for I/O-bound concurrency — network servers, API aggregation, database clients, file crawlers. One event loop thread can saturate a 10 Gb/s NIC while consuming negligible CPU.

Async is the wrong tool for CPU-bound work. A coroutine that computes a Mandelbrot set blocks the event loop for its entire duration, freezing every other connection. Solutions:

import asyncio
from concurrent.futures import ProcessPoolExecutor

_pool = ProcessPoolExecutor(max_workers=4)

async def compute_intensive(data):
    loop = asyncio.get_running_loop()
    # Offload to a separate process — doesn't block the event loop
    result = await loop.run_in_executor(_pool, cpu_heavy_fn, data)
    return result

The pattern: keep I/O on the async event loop, offload CPU to threads or processes. Node.js worker_threads, Python ProcessPoolExecutor, and Go's goroutine + sync channel all express the same idea. Don't call blocking code from an async context without a worker pool.

9. Async pitfalls: what trips up practitioners

After the syntax clicks, these are the bugs that stay:

  • Forgetting await. result = some_coroutine() gives you a coroutine object, not the value. It never runs. Python emits a RuntimeWarning: coroutine was never awaited but only at garbage collection time — your test may pass.
  • Blocking calls on the event loop. time.sleep(1) instead of await asyncio.sleep(1) freezes all other coroutines for one second. Same for requests.get() (blocking) vs aiohttp (async).
  • Async doesn't prevent logical races. If two coroutines share mutable state and both await in between reads and writes, the state can change between your read and your write. The suspension point is your "preemption point".
  • Error propagation in fire-and-forget tasks. asyncio.create_task(coro()) without attaching a .add_done_callback or await-ing the task means exceptions are silently swallowed.
  • Mixing sync and async libraries. A synchronous ORM called from an async handler blocks the loop. Check for async-native libraries (asyncpg, motor, aiomysql) or use run_in_executor.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. A Node.js HTTP server handles 10 000 concurrent connections using a single event loop thread. What is the primary reason this works without spawning 10 000 OS threads?
    • Node.js uses the CPU's hardware multi-threading to parallelize requests invisibly.
    • Non-blocking I/O and epoll/kqueue let one thread monitor all sockets and wake only when data is ready — idle connections consume no CPU.
    • Node.js buffers all requests in memory and processes them one at a time sequentially.
    • The V8 engine spawns hidden threads for each socket internally.
  2. In Python asyncio, you write `result = my_coroutine()` but never `await` it. What happens?
    • The coroutine runs synchronously on the calling thread.
    • Python raises a SyntaxError because coroutines must be awaited.
    • The coroutine object is created but never executed; Python emits a RuntimeWarning when it is garbage collected.
    • The event loop schedules it automatically and runs it at the next iteration.
  3. A Python async web handler calls `time.sleep(2)` instead of `await asyncio.sleep(2)`. What is the effect on other concurrent requests?
    • No effect — the event loop runs in a background thread.
    • The event loop is blocked for 2 seconds; all other connections are frozen during that time.
    • Python automatically converts time.sleep to asyncio.sleep when called from an async context.
    • Other requests are queued and processed sequentially after the sleep completes.
  4. Coroutines use cooperative scheduling while OS threads use preemptive scheduling. Which statement correctly describes a consequence of this difference?
    • Coroutines can race on shared data at any memory access, just like threads.
    • OS threads never race because the kernel controls when they switch.
    • Coroutines only yield at explicit await points, so shared state between two coroutines is safe as long as neither awaits in the middle of a logical operation.
    • Coroutines always run faster than threads because they use the GPU for scheduling.
  5. Your async service does real-time image compression (CPU-bound) for each uploaded file. What is the correct approach in a Python asyncio application?
    • Run the compression directly in the async handler — asyncio handles CPU work efficiently.
    • Wrap the compression in asyncio.sleep(0) to yield to other coroutines periodically.
    • Offload compression to a ProcessPoolExecutor via loop.run_in_executor so the event loop remains unblocked.
    • Switch the entire service to a synchronous framework like Flask for CPU-bound workloads.

Related lessons