libuv is the event loop Node.js was built on, and its design thesis is
distillation: take the event loop pattern — poll readiness, dispatch
callbacks — and reduce it to a vocabulary of two object kinds (handles
and requests) plus one cross-thread escape hatch (the async handle),
so that the same ~500-line core runs on epoll, kqueue, event ports,
IOCP, and less famous pollers unchanged. The loop is not a framework
that owns your program; it is a data structure you embed. This
deep-dive teaches the loop's phase structure (uv_run), the
watcher/pending queue machinery under every platform backend, and the
two designs that make single-threaded async workable in practice:
mutex-plus-eventfd handoff for other threads, and a starvation-guarded
thread pool for blocking work.
Read it after Redis (a bespoke loop) and nginx (a loop per worker process): libuv is what those machines look like when the goal is portability as the product — it is also the missing diagram behind every "Node.js event loop" blog post, in the code.
🧒 Explain like I'm 10
A receptionist who never watches anything for long. The
receptionist (the event loop) keeps a wall of numbered mailboxes — one
per thing she watches (a socket, a timer) — and a big board that
lights up a mailbox when it has news (the poller: epoll on Linux,
kqueue on Macs). She works in rounds: first she checks the alarm clock
list (the timer heap — earliest alarm first), then anything colleagues
left on her desk while she was away (the pending queue), then she takes
one nap on the board's buzzer until some mailbox lights or the next
alarm is due (uv__io_poll), handles what lit up, runs the
end-of-round chores (check handles, closing handles), and starts over.
Some jobs need a delivery van (a blocking syscall like reading a big
file): she hands those to the assistants in the back room (the thread
pool), but the assistants never talk to her directly — they finish,
drop the result in her inbox, and ring a special doorbell
(uv_async_send → an eventfd write) that lights a mailbox on the
board, so she finds out during her normal round.
Why the grown-ups did it this way: one serial round-robin keeps every callback's reasoning single-threaded, while the doorbell trick lets the outside world wake the loop without ever sharing a lock with the callbacks themselves.
1. Architecture overview
flowchart TD
subgraph loop["uv_loop_t (one per embedder thread)"]
subgraph phases["uv_run (src/unix/core.c:429) round"]
T["uv__run_timers — binary min-heap"]
P["uv__run_pending"]
I["uv__run_idle / uv__run_prepare"]
C["uv__run_check / uv__run_closing_handles"]
end
WQ["loop->wq — completions from the pool"]
PQ["pending_queue"]
WCH["watcher_queue → backend registrations"]
end
subgraph backend["poll backend — one file per OS"]
EP["linux.c: uv__io_poll (epoll)"]
KQ["kqueue.c: uv__io_poll (kqueue)"]
PP["posix-poll.c: uv__io_poll (poll)"]
WIN["src/win/: IOCP translation"]
end
subgraph pool["threadpool (src/threadpool.c) — global"]
W1["worker threads"]
SQ["wq + slow_io_pending_wq<br/>slow-IO starvation guard"]
end
API["handles: tcp/udp/pipe/tty/timer/async/prepare/check/idle/poll/process<br/>requests: connect/write/fs/getaddrinfo/work"]
T --> P --> I --> EP --> C
W1 -->|"uv_async_send(loop->wq_async)"| WQ
WQ -->|"uv__work_done"| API
EP --- WCH
KQ --- WCH
PP --- WCH
Diagram source — enable JavaScript to render the diagram.- The loop is a struct, not a singleton.
uv_loop_initgives you auv_loop_twith its own queues, timer heap, backend fd, and ref counts (include/uv.h:1957struct uv_loop_s). Embedding means owning the struct — Node runs one; embedders run several. - The spine of every event: a handle registers interest
(
uv__io_start,src/unix/core.c:955— adds the watcher toloop->watcher_queue, indexed by fd inloop->watchers[]) → nextuv__io_pollflushes pending registration changes to the backend and blocks in epoll/kevent → readiness invokes the watcher's callback → completion work that must defer lands inpending_queue(uv__io_feed,core.c:1062) and runs asPOLLOUTcallbacks inuv__run_pending. - Backends are files, not classes:
src/unix/linux.c:1418(uv__io_pollover epoll),src/unix/kqueue.c:165,src/unix/posix-poll.c:134,src/win/implementing the same internal contract over IOCP — the platform seam is one function signature plus a convention. - Handles vs requests: handles are long-lived (a TCP socket, a
timer) and ref-counted; requests are one-shot operations with a
completion callback (a
write, anfsstat, a DNS lookup). The distinction drives lifetime:uv_closeretires handles at the end of the round (uv__run_closing_handles), while requests retire when their callback fires.
Walkthrough: one timer, one socket read, one threadpool file read
The spine as a story — on the site you can step through it with Next; in the repo it reads top to bottom.
sequenceDiagram
autonumber
participant E as Embedder
participant R as uv_run round (core.c:429)
participant POLL as uv__io_poll (linux.c:1418)
participant TP as threadpool worker (threadpool.c:57)
participant AS as async doorbell
E->>R: uv_timer_start(cb), uv_read_start(tcp), uv_fs_open
R->>R: uv__run_timers: earliest heap entry due?
R->>POLL: watcher_queue flushed → epoll_ctl, epoll_wait
POLL-->>R: tcp readable → uv__stream_io → uv__read
R->>R: alloc_buf + read_cb (zero-copy into your buffer)
TP->>TP: uv__work_submit: open syscall runs off-thread
TP->>AS: push result to loop->wq, uv_async_send (eventfd write)
AS-->>POLL: eventfd readable → wq_async callback
R->>R: uv__work_done drains loop->wq → your fs_cb
R->>R: check/closing handles, timers again — round ends
Diagram source — enable JavaScript to render the diagram.Register interest, lazily
The embedder starts a timer
(leaf in a binary min-heap, src/timer.c), subscribes to socket
reads (uv_read_start → uv__io_start, core.c:955, queuing the
stream's watcher with POLLIN), and calls uv_fs_open — which
packages a uv_fs_t request and calls uv__work_submit
(src/threadpool.c:276) instead of doing the syscall.
The round begins with due timers
uv_run
(src/unix/core.c:429) calls uv__run_timers first (a
backwards-compatibility order for UV_RUN_DEFAULT), which pops heap
entries whose timeout ≤ loop->time into a ready list and fires their
callbacks.
Pending work, then the poll
uv__run_pending drains
completions parked by uv__io_feed (core.c:1062); idle/prepare
handles run if present — embedders use prepare/check as "before and
after poll" hooks. Then uv__backend_timeout (core.c:403) computes
the poll timeout from the next timer (or 0 if anything is pending),
and uv__io_poll (linux.c:1418) flushes the watcher_queue into
epoll, then blocks in epoll_wait for up to that timeout.
Readiness becomes your callback
The socket is readable;
epoll returns; the stream watcher's uv__stream_io calls uv__read
(src/unix/stream.c:1033), which invokes your alloc_cb for a
buffer, read(2)s into it, and calls your read_cb with the slice
— zero copies beyond the one you controlled.
The pool finishes the file open
A worker thread
(threadpool.c:57 worker) took the uv_fs_t off the global wq,
ran the blocking open outside the loop, then — the crucial handoff —
locked loop->wq_mutex, appended the request to the loop's queue,
and called uv_async_send(&loop->wq_async) (threadpool.c inside
worker), which wrote 1 to the loop's eventfd (src/unix/async.c,
uv__async_send).
The doorbell rings through the front door
The eventfd is
itself registered with the poller, so the next poll returns it
readable; the wq_async handle's callback uv__work_done
(threadpool.c:319) swaps out loop->wq under the mutex and invokes
each request's done — your fs_cb runs on the loop thread, with
loop state safely single-threaded.
Close out the round
uv__run_check runs check handles,
uv__run_closing_handles (core.c:370) finishes handles marked by
uv_close (calling their close_cb), timers get one more pass, and
uv__loop_alive (core.c:395) decides whether another round happens:
only if active handles, active requests, pending work, or closing
handles remain — an unref'd timer or async does not keep the process
alive.
Three sources of events, one serial loop
A timer, a socket, and a threadpool completion all became callbacks on the same thread in one round — no application-level locks anywhere.
2. Core data structures — and why these
uv__io_t — the watcher (include/uv/unix.h:90)
struct uv__io_s {
uintptr_t bits; /* callback pointer, bit-packed */
struct uv__queue pending_queue;
struct uv__queue watcher_queue;
unsigned int pevents; /* pending mask — applied at next poll */
unsigned int events; /* current mask — what the backend has */
int fd;
};
One struct per pollable thing, embedded (not pointed to) inside tcp,
pipe, tty, and signal handles. The two-queue design is the pattern to
steal: watcher_queue membership means "my event mask changed, tell
the backend at the next poll" (so uv__io_start is O(1) — it queues
and returns, and uv__io_poll batches the epoll_ctl calls);
pending_queue membership means "fire my callback before the next
poll" (how cross-thread and error completions defer safely). The
callback stored as bit-packed bits keeps the watcher one cache line
of hot state.
The intrusive queue (src/queue.h)
A sentinel-based doubly-linked intrusive list used for every
collection in the loop: watcher queue, pending queue, handle queues,
the threadpool's wq. Why intrusive over container-of-pointers: no
allocations ever (queues link through the objects themselves), O(1)
remove given the node, and uv__queue_data (container_of) recovers the
owner — the same reason the Linux kernel uses them.
The timer min-heap (src/timer.c, src/heap-inl.h)
Timers live in a binary min-heap ordered by timeout (ties broken by
insertion id — timer_less_than, src/timer.c:37); uv__run_timers
pops only the due prefix, and re-inserts repeating timers
(uv_timer_again). Why a heap over a sorted list: start/stop is
O(log n) with no shifting; why over a timing wheel: the use case
(millions of mostly-idle timers with wide spans) fits a heap, and a
wheel's fixed tick granularity would leak into user-visible timing.
The loop's fd table (loop->watchers[], maybe_resize)
Watchers are indexed by fd in a growable array (core.c:955 —
loop->watchers[w->fd] = w). Why an array over a hash map: fds are
dense small integers on every Unix, and the array makes "which
watcher owns this fd" a load, not a lookup. (Windows pays a reverse
mapping because handles aren't small integers — the cost of the
portability seam showing through.)
The threadpool's queues (src/threadpool.c)
A global wq guarded by one mutex+condvar, plus a separate
slow_io_pending_wq with the run_slow_work_message sentinel
(worker, line 57): slow I/O (fsync-class) work may occupy at most
(nthreads+1)/2 workers (slow_work_thread_threshold, line 50), so
a disk-flood cannot starve every DNS lookup. Why process-global: one
pool serves all loops in the process (Node has one loop; embedders
still share), sized by UV_THREADPOOL_SIZE at startup.
3. Concurrency and failure model
- One thread runs callbacks; nobody else touches loop state. Every
cross-thread path funnels through the same shape: producer locks the
loop's
wq_mutexonly long enough to append anduv_async_send(eventfd write on Linux,EVFILT_USERon kqueue, a pipe elsewhere —src/unix/async.c). The loop wakes, drains, runsdonecallbacks. This is the pattern the Redis dive shows in reverse (I/O threads handing clients back to a serial core). - Blocking work is quarantined by kind: CPU-bound and fs operations go to the pool with explicit slow-IO budgets; anything async-capable (sockets, DNS via the pool, signals via a self-pipe) stays on the loop. The embedder's contract is "no blocking in callbacks" — the library cannot enforce it, which is Node's entire performance culture in one sentence.
- Failure is error propagation, not recovery. Syscall failures come
back as
intUV_* codes on requests and handles; a failed read delivers anEOF/error to the callback, never a panic. Handle teardown is the delicate part:uv_close(core.c:161) marks the handle, itsclose_cbfires only inuv__run_closing_handlesafter in-flight requests drain — closing is a protocol, not a free, which is the price of safe cancellation in a callback world. - Loop lifetime is ref counting.
uv__loop_alive(core.c:395) counts active handles and requests;uv_unreflets a handle exist without keeping the process alive (how Node keeps timers that don't prevent exit). Run out of refs anduv_runreturns — graceful exit is "nothing is left to do," and the flags dance insrc/uv-common.h:306-321is where it is paid.
4. API and extension design
- The public API is the object vocabulary: ~15 handle types
(
uv_tcp_t,uv_udp_t,uv_pipe_t,uv_tty_t,uv_timer_t,uv_prepare_t,uv_check_t,uv_idle_t,uv_async_t,uv_poll_t,uv_process_t,uv_signal_t,uv_fs_event_t,uv_fs_poll_t,uv_stream_tas the shared base) and ~10 request types. Handle structs embeduv_handle_tfields via macros (UV_HANDLE_FIELDS), which is how C does inheritance — and the reason ABI layout is versioned carefully (include/uv.h). - The seams for embedders are deliberate:
uv_prepare_t/uv_check_texpose before-poll and after-poll phases so foreign event loops can interleave (this is how Qt, Luvit-era loops, and Node's own integration work);uv_poll_tlets you inject any fd into the loop;uv_async_tis the thread-boundary primitive every embedding needs. - The thread pool is a fixed-size contract (
UV_THREADPOOL_SIZE, capped at 1024 byMAX_THREADPOOL_SIZE): no priorities, no cancellations mid-syscall (uv_cancelonly works while queued). Simple, and honest about OS limits — you cannot cancel an in-flightfsync. - The honest warts: the API's C-inheritance macros make handles awkward to wrap in other languages (every binding re-implements the layout care); error handling is return-code-only with no context; and the "loop is a struct" design means no built-in scheduler fairness — a malicious callback chain starves everything, which Node's ecosystem periodically rediscovers.
5. The defining trade-offs
Trade-off 1: one portable loop core over per-platform optimal designs
- Tension: Node needed the same semantics on Windows (IOCP — completion-based) and Unix (readiness-based), in 2011.
- Choice: normalize everything to "readiness + callbacks" in the
core; on Windows, fake readiness by issuing overlapped reads eagerly
and buffering (
src/win/); on Unix, pick the best poller per platform behinduv__io_poll. - Evidence:
src/unix/core.c:429uv_runcontains zero platform#ifdefs — the phase logic is shared; per-platform code is exactly one function per file (linux.c:1418,kqueue.c:165,posix-poll.c:134; the Windows counterpartuv__pollinsrc/win/core.c:424blocks inGetQueuedCompletionStatusExinstead); the watcher's two-queue design (uv__io_start/uv__io_feed) exists so the core can defer all backend registration to poll time. - Given up: the extra buffering on Windows (an IOCP-native design
would avoid the readiness emulation), and some performance that a
hand-rolled epoll-only loop (à la Redis's
ae) keeps by skipping the general machinery. - Verdict: right when portability is the product — libuv's value
is that millions of lines of Node code run unmodified on five
platforms. Wrong for a single-platform server squeezing the last
10%; write
aeinstead.
Trade-off 2: mutex-plus-async handoff over lock-free cross-thread designs
- Tension: other threads (pool workers, embedder threads) must hand results to the loop without data races, and the loop must not spin waiting for them.
- Choice: producers append to a loop-owned queue under a plain mutex, then write an eventfd — the write is the wakeup, and all callbacks run on the loop thread.
- Evidence: the tail of
worker(threadpool.c) —uv__queue_insert_tail(&w->loop->wq, ...),uv_async_send,uv_mutex_unlock, in that order; the drain sideuv__work_done(threadpool.c:319) swaps the queue under the same mutex and runs callbacks;uv__async_send(src/unix/async.c) is the whole wake-up mechanism, including the eventfd EAGAIN drain. - Given up: cross-thread latency (one loop round of delay, and a syscall per wakeup) and any ability to run callbacks off-loop — a lock-free queue with concurrent callbacks would be faster in microbenchmarks and wildly harder to keep correct under the API's single-threaded promise.
- Verdict: right whenever the callback contract is single-threaded — which is the entire value of an event loop to its embedder. Wrong for throughput-focused pipe stages, where you accept locks in callbacks instead (memcached's worker design) and drop the loop.
Trade-off 3: ref-counted loop lifetime over explicit lifecycle management
- Tension: "when does the loop stop?" needs an answer that embedders cannot get wrong.
- Choice: the loop runs while active handles/requests exist;
handles may
unrefthemselves out of the count; exit is simply "nothing left." - Evidence:
uv__loop_alive(core.c:395) — four conditions, no configuration;uv__backend_timeout(core.c:403) refusing to sleep when work is pending; the ref-flag macros (src/uv-common.h:306-321) implementing add/remove on the active count. - Given up: precise control — a lingering ref'd async or a half-drained request keeps the process alive mysteriously ("why won't Node exit?" is this trade's support cost), and debugging means walking your own handle graph.
- Verdict: right for embedded runtimes where "exit when idle" must
be the default-correct behavior. Wrong for servers with explicit
shutdown choreography — which is why those embedders learn
uv_unrefdiscipline early, or manage their own liveness above the loop.
6. Reading path
include/uv.h:1957struct uv_loop_s— read the fields as a map of the design: queues, timer heap,watchers,backend_fd,wq+wq_async,active_handles. Every section of this dive is one field.src/unix/core.c:429uv_run— the round, start to finish; match each call to a phase and to the walkthrough above. Note the pending-queue drain capped at 8 iterations after poll — a starvation guard in the core itself.src/unix/core.c:955uv__io_startand:1062uv__io_feed— the two queues' entry points; thenuv__run_pendingfor how the pending side fires (asPOLLOUT).src/unix/linux.c:1418uv__io_poll— the flush ofwatcher_queueintoepoll_ctl, theepoll_waitblock, the dispatch loop with its batching counter (count = 48); comparesrc/unix/kqueue.c:165to see how little changes per platform.src/timer.c—uv_timer_startinserting into the heap,timer_less_than(line 37),uv__run_timers, anduv__next_timeout(line 143) feedinguv__backend_timeout(core.c:403).src/unix/stream.c:1033uv__readand:1505uv__read_start— the alloc-callback protocol that keeps data copies user-owned; noticeuv__io_feedon the error path.src/unix/async.c—uv_async_init,uv_async_send, anduv__async_send: the eventfd write (and its EAGAIN handling) that is the loop's entire cross-thread surface; then find where the async watcher is registered like any other poller fd.src/threadpool.c—post(143),worker(57) with the slow-IO threshold dance,uv__work_submit(276),uv__work_done(319). Readworkertwice: once for the queue mechanics, once for the starvation guard comments.src/queue.h— the intrusive queue, whole file; it is the vocabulary every other file speaks.src/unix/core.c:395uv__loop_alive,:370uv__run_closing_handles, and:161uv_close— lifetime and the closing protocol; thensrc/uv-common.h:306-321for the ref counting the lifetime depends on.include/uv/unix.h:90struct uv__io_snext to a handle likeuv_tcp_t— see how the watcher is embedded and why the two-queue design lets one struct serve both registration and deferral.
You have now traced a timer, a socket read, and a threadpool completion through one loop round — and seen the three mechanisms (phases, watcher queues, the async doorbell) that every event loop built on libuv inherits.
7. What to steal
- Separate "what changed" from "fire me now" (the watcher's
watcher_queuevspending_queue): deferring backend registration to poll time makes event-mask updates O(1) and batchable — the quiet trick underuv__io_start. - Cross-thread = mutex + a pollable doorbell (
uv_async_send): never let another thread run your callbacks; make it wake the loop and let the loop do everything (threadpool.ctail). - Budget your worst citizens explicitly: the threadpool's
half-the-workers cap for slow I/O is three lines that prevent an
entire failure mode (
slow_work_thread_threshold). - Make lifetime ref counting, not choreography (
uv__loop_aliveunref): "exit when idle" as a default-correct invariant beats "exit when someone remembers to" every time.
- Phases are an embedding API (
prepare/check): exposing before-poll and after-poll hooks is what lets your loop live inside someone else's program without owning the main thread. - Intrusive queues for zero-allocation hot paths (
src/queue.h): when every operation is a list operation, container allocation is the tax you stop paying.
8. Difficulty and prerequisites
- Difficulty: moderate. Clean small C; the challenge is holding the phase machinery in your head, not parsing syntax.
- Budget: ~3-4 hours for the reading path; libuv is famous for being
readable in a weekend —
core.c,stream.c,threadpool.c, andasync.care the whole story. - Prerequisites:
select/poll/epollat a working level (man pages suffice); condition variables; the Redis dive's event-loop section for a minimalist sibling of this design.
9. Further reading
- libuv documentation — https://docs.libuv.org/ — the design overview page restates the phase model this dive walks.
- N. Marathe, An Introduction to libuv — the community book that first mapped the API to the phases.
- The
src/unix/core.candsrc/threadpool.cheader comments — among the best "why" comments in the systems C canon.