bigonotes
CS ConceptsOS Fundamentals

Async vs Multithreading

How an event loop and multiple threads each avoid wasted waiting, and which to choose for I/O-bound vs CPU-bound work.

5 min readLast updated OS FundamentalsListen · Watch on NotebookLM

Multithreading and async I/O both solve the same problem, "don't waste time waiting," in opposite ways. Multithreading adds more workers that each block; async uses a single worker that never blocks. Picking the right one depends on whether your program spends its time waiting or computing.

The problem#

Most server time is spent waiting, not computing. Take one HTTP request:

Step Time Kind
1. Receive request 1 ms CPU
2. Query database 50 ms Waiting
3. Process result 2 ms CPU
4. Send response 1 ms CPU

The total is 54 ms, but only 4 ms is real CPU work, so ~93% of the time is spent waiting.

Multithreading: more workers#

Give each request its own thread. While Thread 1 waits on the database, Thread 2 handles another request.

Thread Timeline
Thread 1 request, DB wait, process, respond
Thread 2 starts a moment later: request, DB wait, process, respond
Thread 3 starts a moment later again: same steps

Each thread blocks on I/O, and the OS switches between them.

It works, but each thread costs a stack (often ~1 MB), context switches add overhead, and 10,000 concurrent requests means 10,000 threads. A thread pool bounds the count but doesn't change the model.

Async: one worker that never waits#

One thread handles everything. When it hits I/O, it starts the operation, registers what to do when it completes, and moves straight on.

  1. Request 1 arrives: start DB query 1, don't wait.
  2. Request 2 arrives: start DB query 2, don't wait.
  3. Request 3 arrives: start DB query 3, don't wait.
  4. Query 1 is done: process 1, respond 1.
  5. Query 2 is done: process 2, respond 2.
  6. Query 3 is done: process 3, respond 3.

The event loop#

How does the thread know which I/O finished? An event loop repeatedly asks the OS (via epoll on Linux, kqueue on macOS/BSD, IOCP on Windows) which operations are ready:

while True:
    events = os_poll()        # which sockets/files are ready?
    for event in events:
        event.callback()      # continue the work that was waiting on it

Node.js, Python's asyncio, and frameworks like Netty work this way: one loop, thousands of concurrent connections.

import asyncio

async def handle(request_id):
    # await yields control to the event loop while the "query" is in flight
    await asyncio.sleep(0.05)          # stands in for a 50 ms DB call
    return f"response {request_id}"

async def main():
    # 1,000 requests overlap on ONE thread: total ≈ 50 ms, not 50 s
    results = await asyncio.gather(*(handle(i) for i in range(1000)))
    print(len(results))

asyncio.run(main())

Side by side, the difference is how much of each thread's time is useful:

Three threads each do work, sit idle during a long wait while holding a stack, then work again; a single async thread runs work 1, 2 and 3 back to back, then callbacks 1, 2 and 3, with no idle gap

When to use which#

The deciding question is whether your workload mostly waits or mostly computes.

Workload Winner Why
I/O-bound (DB, APIs, files, sockets) Async Mostly waiting; one thread can juggle thousands of connections with little switching
CPU-bound (image processing, compression, ML) Multithreading (or multiprocessing) Real computation needs multiple cores; async has no waiting to overlap
Mixed Both Event loop for I/O, plus a worker pool for heavy CPU tasks

Node.js is a good example of "both": your JavaScript runs on one event loop, while libuv uses a small background thread pool for work like file system calls, DNS lookups and crypto.

Trade-offs#

Each model pays a different price for its strengths.

Multithreading Async
Memory per connection Thread stack (often ~1 MB) Small object for the pending task (KBs)
10,000 connections On the order of GBs of stack On the order of tens of MB
Context switches OS switches between threads Few; one thread runs the loop
CPU-bound work Real parallelism across cores One thread → one core; a long computation blocks every other request
Main bugs Race conditions, locks, deadlocks Callback nesting, accidentally blocking the loop, harder stack traces
Programming model Familiar sequential code Callbacks, promises, async/await

The biggest async pitfall: never block the event loop. A synchronous DB driver or a CPU-heavy loop inside an async handler stalls every other connection. Hand such work to a thread or process pool.

Simple mental model#

Ask what your program spends its time doing:

If it is mostly... Use
Waiting on something Async (don't tie up threads waiting)
Computing something Threads/processes (use every core)
Both Async for I/O + a pool for CPU work

Some examples:

  • API server making many DB/HTTP calls: async.
  • Image processing pipeline: multithreading / multiprocessing.
  • LLM-backed service: both. Async while waiting on the model API, and a pool for heavy parsing or post-processing.

For CPU work you only need as many threads as cores. On 8 cores, 8 threads run truly in parallel; 16 still run only 8 at a time and add switching; 100 is pure waste.

In Python, the GIL changes the CPU-bound answer: use processes, not threads.

Key takeaways#

  • Multithreading overlaps waiting by giving each task its own blocking thread; async overlaps it with one non-blocking event loop.
  • Async wins for I/O-heavy, high-concurrency work because it avoids per-connection stacks and switches.
  • Threads (or processes) win for CPU-bound work because they use multiple cores.
  • Don't block the event loop; offload CPU or blocking calls to a pool.
  • CPU-bound parallelism tops out at one thread per core.

Found a mistake? Tell me on LinkedIn ↗

Further reading#