Event loop or threads?
| Dimension | Event loop | Multithreading |
|---|---|---|
| How waiting works | Sockets are non-blocking; the loop asks epoll, kqueue or IOCP which are ready and runs other callbacks meanwhile | The thread blocks in read() and the OS scheduler runs another thread |
| Memory per connection | Just the state the callback needs, kept in heap objects | A whole stack: typically 8 MB of virtual address space on Linux with glibc, 1 MB reserved on Windows (a few KB for goroutines) |
| Using many cores | One loop per core: Nginx runs a single-threaded worker per core; Redis users run several instances | Built in: threads run in parallel (except CPython with the GIL) |
| CPU-bound work | Blocks every connection; hand it to a worker pool (run_in_executor, Node worker threads) | Fine: it runs on its own core while other threads carry on |
| Shared state and locks | One callback runs at a time, so in-memory state needs no locks (but it can change across an await) | Any thread can touch shared memory at any moment: locks or atomics, with data races and deadlocks possible |
| Typical failure | One slow callback (a big JSON.parse, a heavy regex, a synchronous file read) stalls every client | Deadlocks, races that appear only under load, and pool exhaustion when every thread waits on a slow dependency |
| Debugging | Timing depends only on events, but logic is split across callbacks and stack traces stop at the loop (async/await helps) | Sequential code with real stack traces, but timing-dependent bugs are hard to reproduce |
| Examples | Node.js (libuv), Python asyncio, Nginx, Redis | Thread-pool servers in Java, C++ and Rust; Go goroutines and Java virtual threads as cheap threads |
Rule of thumb
If connections mostly wait on the network and there are thousands of them, an event loop is cheap and avoids locks. If the work is CPU-heavy, or the code and libraries are blocking, use threads, ideally from a pool. Goroutines and Java’s virtual threads blur the line: you write blocking, thread-per-request code, and the runtime parks a waiting thread and runs another on the same OS thread. Either way, keep slow work off the thread that serves everyone else.
Sources
- libuv docs: Design overview (event loop, non-blocking sockets, thread pool)
- Node.js: Don’t block the event loop (or the worker pool)
- Node.js: About Node.js (no locks)
- Python docs: Developing with asyncio (run_in_executor for blocking code)
- Linux man-pages: pthread_create(3) (default stack size)
- Linux kernel: include/uapi/linux/resource.h (_STK_LIM, 8 MB)
- Microsoft Learn: Thread stack size
- Go FAQ: Why goroutines instead of threads?
- OpenJDK: JEP 444, Virtual Threads (asynchronous style, debugging, Little’s law)
- John Ousterhout: Why Threads Are A Bad Idea (for most purposes), slides (1995)
- NGINX blog: Inside NGINX, how we designed for performance and scale
- Redis FAQ: How can Redis use multiple CPUs or cores?
- Python glossary: global interpreter lock
- The Rustonomicon: Data races and race conditions