Concurrency and the event loop
spawn queues a function as a task. Each task gets its own machine stack, so it
can stop half-way through and continue later from the same line. This page shows
the machinery.
Step through the stack swap register by register, watch the argument block being built, follow the libuv park-and-wake handshake, and run thirteen programs covering the whole concurrency API.
This page covers tasks: how they are queued, how a stack swap works, why no locks are needed. The layer underneath — the libuv loop, the timers, and what actually happens when a network request arrives — has its own walkthrough.
▶ Open the interactive event-loop walkthrough
It follows one disk read, one timer and one HTTP request from the system call back to the line of Flow-Wing that was waiting for it.
The three layers
| Layer | File | Job |
|---|---|---|
| Scheduler | fw-modules/gc/src/fw_sched.c | queue tasks, swap stacks, wait on deadlines |
| Event loop | fw-modules/uv_module/fw_uv.c | one shared libuv loop; wait on sockets instead of clocks |
| HTTP | fw-modules/vortex_module/uv_http_*.cpp | server and client, both on that loop |
The stack swap
This is the heart of it. swapcontext on POSIX, Fibers on Windows:
main stack (8 MB) task A (256 KB) task B (256 KB)
┌───────────────┐ ┌─────────────┐ ┌─────────────┐
│ fg_main() │ │ worker("A") │ │ worker("B") │
│ drain() │◄────┐ │ step 1 ⏸ │ │ step 1 │
└───────────────┘ │ └─────────────┘ └─────────────┘
│ frozen running
swapcontext ───┘ mid-call
The saved registers include the stack pointer, so a switch moves execution to a
different block of memory. Task A's frames are untouched, which is why it
resumes on the line after its yield rather than from the top.
Waiting on something that is not a clock
On its own the scheduler can only wait on a deadline — enough for sys::sleep
and nothing else. A task waiting on a socket would have to block the thread and
stop every other task. The event layer plugs in here:
no task ready
↓
fw_sched_drain → g_waiter(max_wait_ns) installed by fw_uv_loop()
↓
uv_run(loop, UV_RUN_ONCE) sleeps in the kernel
↓ socket readable
on_read → parse → fw_sched_wake_io()
↓
task moves back to the ready queue
fw_uv_wake() is the only call in the runtime another thread may make. It wraps
uv_async_send, the one function libuv documents as thread-safe.
Task stacks
Each running task owns a stack — 256 KB by default, much smaller than the main stack. Stacks are taken when a task first runs and released when it finishes, so queuing a million tasks costs a million small records, not a million stacks.
FW_TASK_STACK_KB=4096 ./myprogram
Running off the end of a task stack raises a named error instead of a silent crash:
Runtime Error: Task Stack Overflow.
▶ A spawned task used more stack than it owns.
▶ Raise it with FW_TASK_STACK_KB (e.g. FW_TASK_STACK_KB=4096), or reduce the recursion depth.
Each platform detects it with its own native mechanism, and the message is the same either way:
| How the end of the stack is found | What reports it | |
|---|---|---|
| Linux, macOS | the bottom page is mapped PROT_NONE | a SIGSEGV / SIGBUS handler on its own alternate stack |
| Windows | the kernel's guard page below the fiber's reserve | a vectored handler catching EXCEPTION_STACK_OVERFLOW |
Both check that the fault really belongs to a task stack before claiming it. An
ordinary bad pointer, or recursion that is simply too deep on main, still
produces the normal crash rather than being mislabelled as a task overflow.
The main stack is far larger than a task stack — about 8 MB on Linux and macOS. Windows would give only 1 MB by default, so Flow-Wing asks its linker for 64 MB instead. A Windows x64 frame is bigger (the ABI adds 32 bytes of shadow space per call), so equal byte counts would still mean a shallower limit; the larger reserve keeps the reachable depth comparable across all three. That reserve is address space, not committed memory, so a program that never recurses deeply pays nothing for it.
Why no locks are needed
Tasks are cooperative and single-threaded. Exactly one runs at a time, and a
switch happens only where you suspend — a sys::sleep, a sys::yield, or a
socket read. Two tasks can never be inside the same function at the same
instant, so there is no data race to protect against.
That property is also what lets the garbage collector stay lock-free. See Garbage Collector.
See also
- Advanced → Concurrency with spawn — the language-level guide, with the copy-versus-alias rules for arguments
- Creating a Server — handling requests concurrently
- Built-in Libraries → System (sys) —
sleep,yield, and the monotonic clock