Technopedia

The technologist’s encyclopedia

Asynchrony

Asynchrony handles many I/O operations on a single thread without blocking. It is relevant when a server must handle thousands of concurrent connections, or when a UI must remain responsive while waiting for network responses.

Asynchrony is a prerequisite for distributed systems: non-blocking I/O allows a single process to maintain thousands of network connections, handle timeouts and retries without stalling, and implement circuit breakers. Without it, every connection that waits for a remote node would pin an OS thread, making horizontal scaling impossible at the single-node level.

Authoritative sources

Event loop

A single thread multiplexes many I/O operations. It registers callbacks for events (socket readable, timer expired) and dispatches them in order. The kernel’s epoll (Linux), kqueue (BSD/macOS), or IOCP (Windows) notifies the loop when a file descriptor is ready, avoiding busy-waiting.

Used in Node.js, Nginx (one event loop per worker process), Redis, and the browser’s main thread.

Details

The event loop is a concurrency pattern, not a parallelism pattern: it achieves high throughput on a single core by never blocking. The key insight is that I/O is the bottleneck, not computation: a server spends most of its time waiting for network data, not processing it.

The loop phases (Node.js):

  1. Timers: execute setTimeout/setInterval callbacks.
  2. Pending callbacks: execute system-level callbacks (e.g., TCP errors).
  3. Idle, prepare: internal use.
  4. Poll: retrieve new I/O events; execute I/O callbacks.
  5. Check: execute setImmediate callbacks.
  6. Close callbacks: execute close handlers (e.g., socket.on('close')).

A blocking operation (synchronous file I/O, CPU-intensive computation) stalls the entire loop. This is why Node.js uses worker threads for CPU-bound work, and why Parallelism (thread pools) complements asynchrony: the event loop handles I/O, and worker threads handle computation.

The event loop is the scheduler (runtime mechanism); async/await is the programming model (developer-facing syntax) that lets the developer express continuations without manual callback chaining.

async/await

async/await is syntactic sugar for continuations. await expr suspends the current coroutine and resumes it when expr completes. The event loop drives the coroutines.

Details

async/await desugars into a state machine: each await is a suspension point. The compiler generates an enum (state machine) whose variants represent the states between suspension points. Resuming is a function call into the state machine. This is why async functions are cheap to create (no OS thread) but cannot be cancelled mid-instruction.

In Go, goroutines are the runtime’s coroutines; see Message Passing for the channel model and Parallelism for the M:N scheduler.

In Rust, async fn generates a Future (an enum-based state machine implementing the Future trait). The executor (tokio, or smol as a lightweight alternative) polls the future on the event loop.

Limitations and pitfalls

Asynchrony trades thread isolation for a single-threaded execution model, introducing several practical difficulties.

Details

Callback hell (pre-async/await): chaining multiple asynchronous operations produces deeply nested callbacks that are hard to read and maintain.

// Pre-async/await: three sequential I/O operations
fs.readFile('a.txt', (err, a) => {
  fs.readFile('b.txt', (err, b) => {
    fs.writeFile('out.txt', a + b, (err) => {
      // done
    });
  });
});

Error-handling complexity: in early JavaScript, try/catch cannot cross an await boundary inside a callback. Each callback must handle its own error, and a missed err check silently swallows the failure. async/await restores structured error handling, but only when the entire call chain is async.

Fragmented stack traces: because a coroutine is suspended and resumed across multiple event-loop iterations, the call stack at the point of an error does not include the frames from before the suspension. Debugging requires async-aware tooling (e.g., V8’s --async-stack-traces).

Head-of-line blocking: if one I/O operation is slow (or a callback performs a long synchronous computation), the entire loop is stalled and every other pending connection waits. A single fs.readFileSync on a slow disk can freeze 10,000 connections. Mitigations: offload to worker threads, enforce timeouts, and use circuit breakers (see Fault Tolerance).

Example problem

Problem: A Node.js server receives 10,000 concurrent HTTP requests, each requiring a 50 ms database query. Compare the thread-per-request model (one OS thread per connection) with the event-loop model (one thread, non-blocking I/O). What are the memory and throughput implications?

Solution

Thread-per-request:

Event loop:

Thread-per-requestEvent loop
Memory~80 GB (stacks)~200 MB (fd state + buffers)
Context switches~10,000 per scheduling round0 (single thread)
Throughput ceilingThread creation / switch costI/O bandwidth
Failure modeThread exhaustion → OOMOne blocking call stalls all connections

The event loop wins by orders of magnitude in memory and context-switch cost. The trade-off: a single blocking call (e.g., synchronous file I/O) stalls all 10,000 connections.