1 · The problem the loop exists to solve
A scheduler that can only watch a clock is nearly useless.
Flow-Wing's scheduler is cooperative. It can suspend a task and run
another one. On its own, though, it knows exactly one reason to wake a task up again:
a deadline has passed. That is enough for sys::sleep and nothing else.
Now ask it to wait for a socket. There is no deadline. The only honest answer the bare
scheduler can give is "call read() and block" — and a blocked thread is a
stopped program, because every task shares that one thread.
task A: read(socket) ← thread blocked here
task B: cannot run
task C: cannot run
timer: cannot fire
task A: parked, waiting for bytes task B: running task C: running timer: fires on time
libuv is what turns "wait for a socket" into something the scheduler can idle on, alongside its timers, in a single kernel call.
The scheduler does not know libuv exists. It holds one function pointer, and libuv fills it in:
/* fw_sched.h */ typedef void (*FWWaitFn)(long long max_wait_ns); void fw_sched_set_waiter(FWWaitFn fn); /* NULL = fall back to nanosleep */
That single line is the whole dependency. Link a program that never
opens a socket or a file, and no loop is ever created: the scheduler idles in
nanosleep and libuv's code is never reached.
2 · One loop, three handles, four functions
The entire libuv layer is 81 lines. Here is all of it.
static uv_loop_t g_loop; /* the loop itself */ static uv_async_t g_wake; /* the one handle another THREAD may touch */ static uv_timer_t g_deadline; /* caps how long uv_run is allowed to wait */ static int g_ready = 0; /* has the loop been created yet? */
There is exactly one loop per process, created the first time anything asks for it. A server, a client and a file read all share it — so a program doing all three still has one thread and one loop.
uv_loop_t *fw_uv_loop(void) {
if (g_ready) return &g_loop; /* idempotent */
if (uv_loop_init(&g_loop) != 0) return NULL;
uv_async_init(&g_loop, &g_wake, on_wake);
uv_timer_init(&g_loop, &g_deadline);
uv_unref((uv_handle_t *)&g_deadline); /* see below */
g_ready = 1;
fw_sched_set_waiter(fw_uv_wait); /* ← the handshake */
return &g_loop;
}
uv_unref on the deadline timer, but not on the async handle.
libuv keeps running while any referenced handle is alive.
The deadline timer must not by itself keep the loop alive, or the loop would spin for ever
on a timer that exists only to bound a wait. The async handle is deliberately left
referenced — that is what makes uv_run genuinely sleep waiting for a
wake, instead of returning immediately because it has nothing to watch.
static void fw_uv_wait(long long max_wait_ns) {
if (!g_ready) return;
if (max_wait_ns >= 0) {
uint64_t ms = (uint64_t)(max_wait_ns / 1000000LL);
if (max_wait_ns > 0 && ms == 0) ms = 1; /* never round a real wait to zero */
uv_timer_start(&g_deadline, on_deadline, ms, 0);
}
uv_run(&g_loop, UV_RUN_ONCE); /* ← sleeps in the kernel */
if (max_wait_ns >= 0) uv_timer_stop(&g_deadline);
}
Three different requests share one function:
max_wait_ns | Meaning | What happens |
|---|---|---|
| < 0 | No timer is pending. Only an event can make progress. | No deadline timer. uv_run blocks until a socket, a threadpool
completion or an async wake arrives. |
| > 0 | A task is sleeping and is due in n ns. | Deadline timer armed, so uv_run returns by then at the latest —
even if no I/O ever arrives. |
| == 0 | Poll. Tasks are runnable, but check for I/O anyway. | Timer set to 0 ms, so uv_run drains whatever is ready and returns at
once. Never blocks. |
uv_loop_t *fw_uv_loop(void); /* get (creating on first call) the shared loop */ int fw_uv_ready(void); /* does the loop exist yet? */ void fw_uv_wake(void); /* THE cross-thread call — see section 7 */ const char *fw_uv_version(void); /* uv_version_string() */
3 · The park-and-wake handshake
Two functions carry every socket, file and timer wait in the language. Step through one.
void fw_sched_park_io(void) {
FWTask *t = g_current;
if (t == NULL) return; /* not in a task: no-op */
t->wake_at_ns = FW_WAKE_NEVER; /* = -1 */
t->shadow_top = fw_gc_shadow_top;
t->state = FW_TASK_SUSPENDED;
queue_push(t);
swapcontext(&t->ctx, &g_sched_ctx);
}
void fw_sched_wake_io(void) {
for (size_t i = g_head; i < g_tail; i++) {
FWTask *t = g_ready[i];
if (t != NULL && t->wake_at_ns == FW_WAKE_NEVER)
t->wake_at_ns = 0; /* runnable now */
}
}
A parked task stays in the ready queue. It is not moved to a side list. The single
field wake_at_ns encodes all three states:
wake_at_ns | State | Set by |
|---|---|---|
| 0 | runnable right now | fresh spawn, or fw_sched_wake_io |
| a timestamp | sleeping until then | sys::sleep |
| −1 (FW_WAKE_NEVER) | parked on an event | fw_sched_park_io |
fw_sched_wake_io wakes EVERY parked task, not the one the event belongs to.
That looks wrong and is deliberate. The event layer has no idea which task was waiting for
which socket. So every caller of park_io is written as a loop that re-checks its
own condition and parks again if it is still not satisfied:
while (!job.done) { /* file read */
fw_sched_park_io();
}
for (;;) { /* http accept */
if (!s->ready.empty()) { ... return r; }
fw_sched_park_io();
}
A wake that turns out to be for somebody else costs one loop iteration, and the task parks
again. Getting this wrong in the other direction — sleeping through a wake — would hang.
Watch one wait, end to end
file::readText and the program keeps working.4 · Who calls the waiter, and when
The scheduler's drain loop decides between "run a task" and "sleep in the kernel".
while (g_head < g_tail) {
/* (A) THROTTLED POLL — tasks ARE runnable, but check for I/O anyway */
if (g_waiter != NULL && fw_sched_io_waiting() > 0) {
long long now = now_ns();
if (now - last_poll_ns >= 1000000LL) { /* at most once per ms */
last_poll_ns = now;
g_waiter(0); /* poll, never block */
}
}
FWTask *t = pop_ready();
if (t == NULL) {
/* (B) NOTHING CAN RUN — work out what could change that */
long long due = earliest_deadline();
unsigned long io = fw_sched_io_waiting();
if (due == 0 && io == 0) break; /* no timers, no events: done */
if (g_waiter != NULL) {
g_waiter(due != 0 ? (due - now_ns()) : FW_WAKE_NEVER);
} else if (due != 0) {
block_ns(due - now_ns()); /* no event layer: nanosleep */
} else {
break; /* (C) see below */
}
continue;
}
/* ... swap to the task's stack ... */
}
fw_sched_drainBranch (B) only runs when nothing is ready. A task that
spins on sys::yield() — a game loop calling yield once per frame is the obvious
case — keeps the ready queue permanently non-empty, so (B) is never reached and a parked
socket read never completes.
Measured before this branch existed: a loop yielding
2,000,000 times left five file reads at zero completed, for the life
of the program. After: done=5 after 155 ms. Timers never had the problem,
because pop_ready promotes tasks whose deadline has passed — which is why
sleeping worked where yielding hung.
Tasks are parked on events, there is no timer, and no event layer is installed. Nothing in the process can ever deliver the event they are waiting for.
Releasing them would be wrong — they would wake, see no data and park again, for ever. Hanging in silence is worse. So the loop breaks and the program ends. A deadlock that ends is easier to debug than one that does not.
One tick, animated
5 · Timers
Two completely different timers, easy to confuse.
sys::sleepNot a libuv timer at all. sys::sleep(200) writes a
timestamp into the task and pushes it back on the queue:
t->wake_at_ns = now_ns() + ms * 1000000LL; t->state = FW_TASK_SUSPENDED; queue_push(t); swapcontext(...);
pop_ready() later finds it due and runs it. Sleeping is a number
comparison, not a kernel object. A million sleeping tasks cost a million integers.
This is also why sleeping worked before libuv existed, and why it still works in a program that never touches a socket.
g_deadlineExactly one exists, and its callback does nothing:
static void on_deadline(uv_timer_t *handle) {
(void)handle; /* exists only to cap how long uv_run waits */
}
Its only job is to make uv_run return. The scheduler needs to regain
control when the earliest sleeping task comes due, even if no socket ever speaks.
It is uv_unref'd, so it never keeps the loop alive on
its own, and it is stopped again the moment uv_run returns.
task A sleeps 200 ms → wake_at_ns = now + 200ms
task B parks on socket → wake_at_ns = -1
nothing ready
drain: due = (A's deadline) io = 1
g_waiter(due - now) == fw_uv_wait(200_000_000)
↓
uv_timer_start(&g_deadline, on_deadline, 200, 0)
uv_run(UV_RUN_ONCE) ← ONE kernel sleep watching
the socket AND the 200 ms cap
↓
whichever happens first wins:
socket speaks → on_read → ... → fw_sched_wake_io() → B runnable
200 ms elapse → on_deadline (does nothing) → uv_run returns
→ pop_ready() finds A due → A runs
There is no polling and no spare thread. One
epoll_wait / kqueue / IOCP call covers every timer and every socket
in the program at once.
uv_timer_start takes milliseconds; the scheduler thinks in
nanoseconds. A 200 µs wait would truncate to 0 ms, which libuv reads as "fire
immediately" — turning a short sleep into a busy spin. Hence
if (max_wait_ns > 0 && ms == 0) ms = 1;. A sub-millisecond sleep is
rounded up to 1 ms rather than down to a spin.
6 · A network request, arrival to handler
Follow one HTTP request through the server. Nothing here runs on a second thread.
app.accept().int fw_http_server_listen(int64_t handle, int port) {
uv_tcp_init(fw_uv_loop(), &s->listener); /* shared loop */
s->listener.data = s;
uv_ip4_addr("0.0.0.0", port, &addr);
uv_tcp_bind(&s->listener, ..., 0);
uv_listen((uv_stream_t *)&s->listener, 128, on_connection);
...
}
128 is the kernel's backlog: connections the OS accepts
and holds for you while your code is busy. A brief burst does not get refused — it queues.
void on_connection(uv_stream_t *server_stream, int status) {
FwConn *c = new FwConn();
uv_tcp_init(fw_uv_loop(), &c->handle);
uv_accept(server_stream, (uv_stream_t *)&c->handle);
llhttp_init(&c->parser, HTTP_REQUEST, &c->settings); /* per-connection parser */
uv_timer_init(fw_uv_loop(), &c->idle);
uv_unref((uv_handle_t *)&c->idle); /* must not keep the loop alive alone */
arm_idle(c);
uv_read_start((uv_stream_t *)&c->handle, alloc_buffer, on_read);
}
Every connection gets its own llhttp parser and its
own idle timer. The parser is a state machine, so a request split across five TCP
packets is fine — each on_read feeds bytes in and the parser remembers where it
got to.
void on_read(uv_stream_t *s, ssize_t nread, const uv_buf_t *buf) {
FwConn *c = s->data;
if (nread < 0) { /* peer hung up */
free(buf->base);
conn_close(c);
fw_sched_wake_io(); /* waiter must re-check */
return;
}
arm_idle(c); /* alive: restart the clock */
llhttp_execute(&c->parser, buf->base, nread);
free(buf->base);
...
}
int on_message_complete(llhttp_t *p) {
FwConn *c = p->data;
FwRequest *r = new FwRequest();
r->method = llhttp_method_name(...);
r->path = c->cur_url;
r->body = c->cur_body;
r->keep_alive = llhttp_should_keep_alive(p);
c->server->ready.push_back(r); /* hand to Flow-Wing */
fw_sched_wake_io(); /* a task may be in accept() */
return 0;
}
app.accept()int64_t fw_http_accept(int64_t handle) {
FwServer *s = ...;
for (;;) {
if (!s->ready.empty()) { /* a request is waiting: take it */
FwRequest *r = s->ready.front();
s->ready.pop_front();
return (int64_t)r;
}
if (!fw_sched_in_task()) { /* no scheduler to yield to */
uv_run(fw_uv_loop(), UV_RUN_ONCE);/* drive the loop directly */
continue;
}
fw_sched_park_io(); /* suspend just this task */
}
}
spawn serverLoop(app) matters.
Inside a task, accept() parks and the scheduler runs your queued handlers.
At the top level there is no task to suspend, so it falls into
uv_run and drives the loop itself — the program still works, but nothing
else of yours gets a turn.
| Limit | Where | What happens when it trips |
|---|---|---|
| header bytes | on_url, on_header_bytes | callback returns -1, llhttp aborts, 400 + close |
| body bytes | on_body | callback returns -1, 413 Payload Too Large + close |
| idle timeout | uv_timer per connection | on_idle_timeout → conn_close + fw_sched_wake_io |
| backlog | uv_listen(…, 128, …) | the kernel refuses further pending connections |
All four are enforced before your Flow-Wing code ever sees the request. A client that opens a socket, sends one byte and goes quiet is dropped by its own idle timer without any task waking up for it.
7 · Disk reads, and the threadpool rule
Files are the one place Flow-Wing genuinely uses another thread — and the one place the rules get strict.
There is no portable way to wait on a regular file with
epoll. libuv's answer is a small worker pool: hand it a blocking job, it runs it on
a spare thread and calls you back on the loop thread when it is finished.
const char *file_read_all(const char *path) {
if (fw_sched_in_task() && fw_uv_loop() != nullptr) {
FwReadJob job; /* lives on the TASK's stack */
job.path = path;
job.req.data = &job;
if (uv_queue_work(fw_uv_loop(), &job.req, read_work, read_done) == 0) {
while (!job.done) {
fw_sched_park_io(); /* other tasks run here */
}
return alloc_gc_string(job.data); /* back on the FW thread */
}
}
/* Outside a task there is nothing to switch to: read inline. */
...
}
void read_work(uv_work_t *handle) {
FwReadJob *job = handle->data;
std::ifstream file(job->path, ...);
if (!file) { job->error = 1; return; }
std::ostringstream contents;
contents << file.rdbuf();
job->data = contents.str(); /* std::string! */
}
no GCno schedulerplain C++ only
void read_done(uv_work_t *handle, int status) {
FwReadJob *job = handle->data;
if (status != 0 && job->error == 0) job->error = 2;
job->done = true;
fw_sched_wake_io(); /* safe here */
}
GC allowedscheduler allowed
std::string is protecting.
The garbage collector is single-threaded and has no locks — that is a design choice the whole
runtime depends on. Calling fw_gc_alloc from a worker thread would corrupt the
heap, silently and unreproducibly.
So read_work builds a plain std::string,
and the GC string is allocated later, back on the Flow-Wing thread, once the task resumes. The
cost is one copy. The alternative is making the GC thread-safe, which would mean locks on every
allocation in the language.
FwReadJob job;, not
new). That is safe precisely because the task cannot return until
job.done is true — its stack is frozen, not unwound, while it is parked. A task
stack is a real block of memory that stays put; see the
tasks page.
8 · fw_uv_wake() — the one call another thread may make
Everything else in the runtime is single-threaded. This is the crack in the wall, and it is one function wide.
void fw_uv_wake(void) {
if (g_ready) uv_async_send(&g_wake);
}
static void on_wake(uv_async_t *handle) { /* runs on the FW thread, in uv_run */
(void)handle;
fw_sched_wake_io();
}
uv_async_send is the only function libuv documents as safe to call from a
thread other than the loop's own. Everything else in libuv — every
uv_read_start, every uv_timer_start — must happen on the loop thread.
The HTTP client in libflowwing_vortex.cpp runs its request
on a std::thread (it uses cpp-httplib, which is blocking). Meanwhile the Flow-Wing
thread may be fast asleep inside uv_run. When a chunk arrives on the worker thread,
something has to reach across and wake the sleeper:
[chunk arrives on worker thread]
std::lock_guard<std::mutex> lock(ctx->mtx);
ctx->chunks.push(std::string(data, len));
ctx->cv.notify_all();
fw_uv_wake(); /* ← nudge the loop thread */
[Flow-Wing thread, parked in uv_run]
on_wake → fw_sched_wake_io() → the reading task becomes runnable
it re-takes the mutex, sees a chunk, and returns it
Without the wake, the chunk would sit in the queue and the loop would keep sleeping — there is no socket it is watching, because the socket belongs to the other thread.
if (fw_sched_in_task()) {
for (;;) {
{ std::lock_guard lock(ctx->mtx);
if (ctx->headers_received) break; }
fw_sched_park_io(); /* others run */
}
}
else {
std::unique_lock lock(ctx->mtx);
ctx->cv.wait(lock, [ctx]{
return ctx->headers_received;
}); /* thread blocks */
}
Both branches are correct. The difference is what stops: one task, or the whole program. This
pairing — fw_sched_in_task() deciding between suspending and blocking — appears in
every waiting primitive in the runtime.
uv_async_send may collapse several sends into
one callback. Nothing depends on the count, because fw_sched_wake_io simply marks
every parked task runnable and each one re-checks its own condition. Firing it a hundred times
and firing it once have the same effect — which is exactly what makes it safe to call from a
thread with no coordination.
test_uv.c · test_repeated_wakes_are_coalesced_safely
9 · Rules worth memorising
The short version of everything above.
One loop, one thread
Server, client and file reads all share g_loop. Creating a second server does
not create a second loop or a second thread.
The loop only runs when nothing else can
uv_run is called from the scheduler's idle branch — or, once per millisecond,
as a non-blocking poll when tasks are busy. Your code is never pre-empted.
A wake is a hint, not a delivery
fw_sched_wake_io wakes everyone. Every park_io must sit inside a
loop that re-checks its own condition.
Never touch the GC off-thread
Threadpool work builds plain C++ values. GC objects are allocated in the completion callback, back on the Flow-Wing thread.
fw_uv_wake() and nothing else
It is the only runtime call another thread may make. It wraps
uv_async_send, the only call libuv documents as thread-safe.
Sleeping is not a libuv timer
sys::sleep is an integer comparison in the scheduler. The single libuv timer
exists only to bound how long uv_run may wait.
A top-level while true starves everything
Queued tasks only drain after the top-level body finishes. Put the accept loop in a task:
spawn serverLoop(app).
No locks in your code
Exactly one task runs at a time and switches happen only where you suspend. Two tasks can never be inside the same function at the same instant.
| File | Lines | Owns |
|---|---|---|
| fw-modules/uv_module/fw_uv.c | 81 | the loop, the waiter, the async wake |
| fw-modules/gc/src/fw_sched.c | ~800 | tasks, stacks, the drain loop, park/wake |
| fw-modules/vortex_module/uv_http_server.cpp | 566 | listener, connections, llhttp, limits |
| fw-modules/vortex_module/uv_http_client.cpp | 507 | the libuv-native client |
| fw-modules/vortex_module/libflowwing_vortex.cpp | — | the threaded client — the one place fw_uv_wake is needed |
| fw-modules/file_module/libflowwing_file.cpp | — | threadpool file reads |
| fw-modules/uv_module/tests/test_uv.c | 20 checks | the loop and the cross-thread wake, threads included |
| fw-modules/gc/tests/test_sched.c | 49 checks | park/wake, the waiter hook, nested drain |