Inside the Flow-Wing event loop

One libuv loop, one thread, no locks in your code. This page follows a timer, a socket and a disk read from the system call all the way back to the line of Flow-Wing that was waiting for it. Every claim is read from fw-modules/uv_module/, fw-modules/vortex_module/ and fw-modules/gc/src/fw_sched.c. Companion pages: tasks and spawn · garbage collector.

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.

Without an event layer
task A: read(socket)   ← thread blocked here
task B: cannot run
task C: cannot run
timer:  cannot fire
With the event layer
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 seam between the two halves

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.

fw-modules/gc/include/fw_sched.h · lines 90–121

2 · One loop, three handles, four functions

The entire libuv layer is 81 lines. Here is all of it.

The only state
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?        */
fw-modules/uv_module/fw_uv.c · lines 23–26

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.

Creation — and the moment the two halves are joined
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;
}
Why 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.
fw-modules/uv_module/fw_uv.c · lines 57–72
The waiter — the function the scheduler calls when nothing can run
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_nsMeaningWhat happens
< 0No 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.
> 0A 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.
== 0Poll. 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.
fw-modules/uv_module/fw_uv.c · lines 43–55
The complete public surface
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.

Going to sleep
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);
}
Waking up
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_nsStateSet by
0runnable right nowfresh spawn, or fw_sched_wake_io
a timestampsleeping until thensys::sleep
−1 (FW_WAKE_NEVER)parked on an eventfw_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

0 / 0
Press Step. A task calls 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-modules/gc/src/fw_sched.c · inside fw_sched_drain
(A) why the throttled poll exists

Branch (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.

(C) the deliberate give-up

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

0 / 0
Ready queue
Thread timeline
Three tasks: one sleeps on a clock, one waits on a socket, one just computes.

5 · Timers

Two completely different timers, easy to confuse.

Scheduler timer — sys::sleep

Not 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.

libuv timer — g_deadline

Exactly 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.

How the two combine
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.

The 1 ms floor. 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.

0 / 0
A client connects to a Flow-Wing server that is parked in app.accept().
Setting up the listener
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.

fw-modules/vortex_module/uv_http_server.cpp · lines 396–414
A connection arrives
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.

fw-modules/vortex_module/uv_http_server.cpp · lines 340–369
Bytes in
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);
  ...
}
A whole request is in
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;
}
The Flow-Wing side of 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      */
  }
}
The two branches are why 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.
fw-modules/vortex_module/uv_http_server.cpp · lines 416–434
The limits, and which layer enforces each
LimitWhereWhat happens when it trips
header byteson_url, on_header_bytes callback returns -1, llhttp aborts, 400 + close
body byteson_body callback returns -1, 413 Payload Too Large + close
idle timeoutuv_timer per connection on_idle_timeout → conn_close + fw_sched_wake_io
backloguv_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. */
  ...
}
fw-modules/file_module/libflowwing_file.cpp · lines 114–142
read_work — worker thread
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
read_done — Flow-Wing thread, inside uv_run
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
The rule that the 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.

The job lives on the task's own stack (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.

fw-modules/uv_module/fw_uv.c · lines 31–34, 76–78
Why it is needed at all

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.

fw-modules/vortex_module/libflowwing_vortex.cpp · lines 120–182
Inside a task
if (fw_sched_in_task()) {
  for (;;) {
    { std::lock_guard lock(ctx->mtx);
      if (ctx->headers_received) break; }
    fw_sched_park_io();     /* others run */
  }
}
Outside a task
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.

Repeated wakes are coalesced. 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.

Where each piece lives
FileLinesOwns
fw-modules/uv_module/fw_uv.c81 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.cpp566 listener, connections, llhttp, limits
fw-modules/vortex_module/uv_http_client.cpp507 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.c20 checks the loop and the cross-thread wake, threads included
fw-modules/gc/tests/test_sched.c49 checks park/wake, the waiter hook, nested drain