Skip to main content

The libuv event loop

The scheduler on its own can only wait on a clock. This page is about the layer that lets it wait on a socket instead — how a timer bounds the wait, how an arriving request wakes a parked task, and what happens when work has to leave the thread entirely.

Interactive version

Step through the idle handshake state by state, follow a request from the kernel to your handler, and watch the threadpool handoff.

▶ Open the interactive event-loop walkthrough

The seam​

The scheduler does not know libuv exists. It declares a hole and calls a function pointer:

typedef void (*FWWaitFn)(long long max_wait_ns);
void fw_sched_set_waiter(FWWaitFn fn); /* NULL = fall back to nanosleep */

The first call to fw_uv_loop() fills that hole in. From then on, whenever no task can run, the thread idles inside uv_run instead of nanosleep — so a socket and a timer can both end the wait.

If nothing ever installs a waiter, programs still work. They just cannot wait on anything but a clock.

Three handles, four functions​

HandleTypeJob
g_loopuv_loop_tthe one loop; every socket, timer and job in the process
g_wakeuv_async_tthe doorbell another thread may ring — stays referenced
g_deadlineuv_timer_tcaps how long one uv_run may block — unreferenced
uv_loop_t *fw_uv_loop(void); /* create-once, and install the waiter */
int fw_uv_ready(void);
void fw_uv_wake(void); /* the one cross-thread call */
const char*fw_uv_version(void);

How long to wait​

max_wait_nsMeaninguv_run
> 0a timer is due in N nsblocks, but no longer than N
0pollreturns at once
-1no timer pendingblocks until an event arrives

sys::sleep does not create a libuv timer — it writes a wake-up instant onto the task and lets pop_ready() promote it later. Sleeping is pure scheduler bookkeeping. g_deadline exists only so uv_run cannot overshoot the nearest of those instants; its callback body is empty.

The one rule about threads​

uv_async_send is the only libuv function documented as thread-safe. fw_uv_wake() is a thin wrapper over it, and it is the only call in the whole runtime that another thread may make. Every other uv_*, scheduler and GC function is FlowWing-thread only.

A worker thread must touch nothing the GC owns. The collector is single-threaded, so calling fw_gc_alloc from a threadpool thread corrupts the heap. Off-thread code works with plain std::string; the GC object is built afterwards, back on the FlowWing thread.

Park, wake, re-check​

fw_sched_wake_io() releases every event-parked task, not the one task the event belongs to. That is correct because every caller re-checks its own condition and parks again:

while (!job.done) fw_sched_park_io(); /* file read */
while (s->ready.empty()) fw_sched_park_io(); /* accept */
while (ctx->chunks.empty() && !ctx->is_done) fw_sched_park_io(); /* read chunk */

A spurious wake costs one queue scan and one stack switch. A wait-set per socket would cost allocation, bookkeeping, and a class of bugs where a task stays parked because the single event it registered for never arrives.

See also​