Inside Flow-Wing concurrency — spawn, tasks and libuv

Click through each mechanism. Every number on this page was measured, not sketched. Companion pages: the libuv event loop · the garbage collector.

0 · Every API, as a program you can run

Thirteen programs covering the concurrency API. Each output below was captured by compiling and running the program on the left.

Run any of them
FW=build/sdk/bin/FlowWing
$FW myprogram.fg --emit=exe -o /tmp/x && /tmp/x

Jump to: spawn · spawn with arguments · sys::yield() · sys::sleep() · the monotonic clock · task stacks, default size · task stack overflow · the null literal and map.get() · HTTP server · Response · HTTP client · Concurrent server · What spawn refuses

spawn — queue a task

spawn f()
program
/; ===========================================================================
/; FEATURE 1 — the `spawn` keyword
/;
/; `spawn f()` does NOT call f. It puts f on a queue. The queue is drained
/; after the top-level body finishes.
/; ===========================================================================

fun greet() -> nthg {
  println("  [task] hello from a task")
}

fun farewell() -> nthg {
  println("  [task] goodbye from a task")
}

println("1. before spawn")
spawn greet()
spawn farewell()
println("2. after spawn  <-- neither task has run yet")
println("3. top level ends here; the drain starts")
real output
1. before spawn
2. after spawn  <-- neither task has run yet
3. top level ends here; the drain starts
  [task] hello from a task
  [task] goodbye from a task

spawn with arguments

spawn f(a, b)
program
/; ===========================================================================
/; FEATURE 2 — spawn WITH ARGUMENTS
/;
/; Arguments are evaluated at the spawn site and copied into a GC-traced block.
/; What the task sees depends on the type:
/;   int / str / dyn / array  -> COPIED  (caller changes are invisible)
/;   object / class           -> POINTER (shared; changes visible both ways)
/;   global                   -> read when the task RUNS, not when it spawned
/; ===========================================================================

type Box = { n: int, label: str }

var g_counter: int = 1

fun show(n: int, s: str, arr: int[3], b: Box) -> nthg {
  println("  int   = " + String(n)          + "   (copied)")
  println("  str   = " + s                  + "   (copied)")
  println("  arr[0]= " + String(arr[0])     + "   (copied)")
  println("  box.n = " + String(b.n)        + " (SHARED - caller changed it)")
  println("  global= " + String(g_counter)  + " (read at run time)")
}

var n: int = 10
var s: str = "orig" + "inal"
var arr: int[3] = [7, 8, 9]
var box: Box = { n: 100, label: "b" }

spawn show(n, s, arr, box)

/; every one of these happens BEFORE the task runs
n = 999
s = "CHANGED"
arr[0] = 999
box.n = 999
g_counter = 999

println("caller changed everything; now the task runs:")
real output
caller changed everything; now the task runs:
  int   = 10   (copied)
  str   = original   (copied)
  arr[0]= 7   (copied)
  box.n = 999 (SHARED - caller changed it)
  global= 999 (read at run time)

sys::yield() — hand over the CPU

sys::yield()
program
/; ===========================================================================
/; FEATURE 3 — sys::yield()  (cooperative switching)
/;
/; yield parks the task and puts it at the BACK of the queue. The task keeps
/; its own stack, so it resumes exactly where it stopped.
/; ===========================================================================

bring sys

fun worker(name: str) -> nthg {
  println("  " + name + " step 1")
  sys::yield()
  println("  " + name + " step 2")
  sys::yield()
  println("  " + name + " step 3")
}

spawn worker("A")
spawn worker("B")

println("watch A and B take turns:")
real output
watch A and B take turns:
  A step 1
  B step 1
  A step 2
  B step 2
  A step 3
  B step 3

sys::sleep() — suspend, do not block

sys::sleep(ms)
program
/; ===========================================================================
/; FEATURE 4 — sys::sleep() inside a task SUSPENDS, it does not block
/;
/; 5 tasks each wait 200 ms. If sleep blocked the thread this would take
/; 1000 ms. Because each task parks, the total is about 200 ms.
/; ===========================================================================

bring sys

var done: int = 0

fun waiter(id: int, start: int64) -> nthg {
  sys::sleep(200)
  done = done + 1
  println("  task " + String(id) + " woke at "
          + String(Int64(sys::elapsedNanos(start) / 1000000l)) + " ms")
}

fun reporter(start: int64) -> nthg {
  sys::sleep(400)
  println("  all " + String(done) + " finished. Serial would need 1000 ms.")
}

var t0: int64 = sys::nanos()
for var i: int = 0 to 4 {
  spawn waiter(i, t0)
}
spawn reporter(t0)

println("5 tasks, each sleeping 200ms:")
real output
5 tasks, each sleeping 200ms:
  task 0 woke at 205 ms
  task 1 woke at 205 ms
  task 2 woke at 205 ms
  task 3 woke at 205 ms
  task 4 woke at 205 ms
  all 5 finished. Serial would need 1000 ms.

the monotonic clock

sys::nanos() micros() millis() elapsedNanos()
program
/; ===========================================================================
/; FEATURE 5 — the monotonic clock
/;
/; sys::timestamp() is SECONDS. It cannot measure anything fast, and it jumps
/; when the system clock changes. These four are nanosecond-precision and
/; never go backwards.
/; ===========================================================================

bring sys

var t0: int64 = sys::nanos()

var sum: int64 = 0
for var i: int = 0 to 500000 {
  sum = sum + 1
}

println("  nanos()        = " + String(sys::nanos()))
println("  micros()       = " + String(sys::micros()))
println("  millis()       = " + String(sys::millis()))
println("  elapsedNanos() = " + String(sys::elapsedNanos(t0)) + " ns for 500k adds")
println("  same in ms     = " + String(Int64(sys::elapsedNanos(t0) / 1000000l)) + " ms")
real output
nanos()        = 1408735135411166
  micros()       = 1408735135473
  millis()       = 1408735135
  elapsedNanos() = 2967042 ns for 500k adds
  same in ms     = 2 ms

task stacks, default size

FW_TASK_STACK_KB
program
/; ===========================================================================
/; FEATURE 8 — task stacks: size control + overflow protection
/;
/; Every task gets its OWN machine stack (default 256 KB). That is what lets
/; it suspend mid-call and resume later.
/;
/; Two things worth knowing:
/;   FW_TASK_STACK_KB=<n>  choose the size
/;   a guard page          turns silent corruption into a NAMED error
/;
/; Try:
/;   FW_TASK_STACK_KB=64  FlowWing 08_stack.fg --emit=exe -o /tmp/s && /tmp/s
/;   FW_TASK_STACK_KB=16  ...   <- deep task now overflows, cleanly
/; ===========================================================================

var depth: int = 0

fun recurse(n: int) -> nthg {
  depth = n
  if n > 2000 {
    return:
  }
  recurse(n + 1)
}

fun deepTask() -> nthg {
  recurse(0)
  println("  recursion reached depth " + String(depth))
}

/; 300 tasks parked at once = 300 live stacks at the same time
var awake: int = 0
fun parked() -> nthg {
  awake = awake + 1
}

spawn deepTask()
for var i: int = 0 to 299 {
  spawn parked()
}

println("one deep task + 300 shallow tasks:")
real output
one deep task + 300 shallow tasks:
  recursion reached depth 2001

task stack overflow

FW_TASK_STACK_KB=16
program
/; ===========================================================================
/; FEATURE 8 — task stacks: size control + overflow protection
/;
/; Every task gets its OWN machine stack (default 256 KB). That is what lets
/; it suspend mid-call and resume later.
/;
/; Two things worth knowing:
/;   FW_TASK_STACK_KB=<n>  choose the size
/;   a guard page          turns silent corruption into a NAMED error
/;
/; Try:
/;   FW_TASK_STACK_KB=64  FlowWing 08_stack.fg --emit=exe -o /tmp/s && /tmp/s
/;   FW_TASK_STACK_KB=16  ...   <- deep task now overflows, cleanly
/; ===========================================================================

var depth: int = 0

fun recurse(n: int) -> nthg {
  depth = n
  if n > 2000 {
    return:
  }
  recurse(n + 1)
}

fun deepTask() -> nthg {
  recurse(0)
  println("  recursion reached depth " + String(depth))
}

/; 300 tasks parked at once = 300 live stacks at the same time
var awake: int = 0
fun parked() -> nthg {
  awake = awake + 1
}

spawn deepTask()
for var i: int = 0 to 299 {
  spawn parked()
}

println("one deep task + 300 shallow tasks:")
real output
Runtime Error: Task Stack Overflow.
  ▶ A spawned task used more stack than it owns.
  ▶ A task stack is much smaller than main's.
  ▶ Raise it with FW_TASK_STACK_KB (e.g. FW_TASK_STACK_KB=4096), or reduce the recursion depth.

the null literal and map.get()

null, map::Map.get()
program
/; ===========================================================================
/; `null` and map::Map.get()
/;
/; `null` is a dyn value carrying the NIRAST tag. It behaves the same whether
/; you hold it in a variable or pass it straight to a function.
/;
/; map.get(key) is getOr(key, null), so a key that is not present comes back
/; as null. Use getOr(key, default) when you would rather have a value.
/; ===========================================================================

bring map

fun takesDyn(v: dyn) -> nthg {
  println("  passed as argument = " + String(v))
}

var held = null
println("  held in a variable = " + String(held))
takesDyn(null)
takesDyn(held)

var m: map::Map = new map::Map()
m.set("present", "here")

println("  map present        = " + String(m.get("present")))
println("  map MISSING        = " + String(m.get("absent")))
println("  map getOr default  = " + String(m.getOr("absent", 42)))
real output
held in a variable = null
  passed as argument = null
  passed as argument = null
  map present        = here
  map MISSING        = null
  map getOr default  = 42

HTTP server — listen, accept, respond

vortex::Server listen() accept() · Request getMethod() getPath() getBody() · Response status() header() send()
program
/; ===========================================================================
/; FEATURE 6 — the Vortex HTTP SERVER, now libuv-native
/;
/; One uv_tcp_t per connection on the shared event loop — no thread per
/; connection.
/;
/; accept() parks the task when no request is ready, so other tasks keep
/; running while the server waits.
/;
/; Run it, then in another terminal:
/;   curl localhost:8080/hello
/; ===========================================================================

bring vortex
bring Err
bring sys

fun ticker() -> nthg {
  /; proves the process is NOT blocked while the server waits for a request
  for var i: int = 0 to 4 {
    sys::sleep(300)
    println("  [ticker] still alive " + String(i))
  }
}

fun fg_main() -> nthg {
  var app: vortex::Server = new vortex::Server()
  var err: Err::Result = app.listen(8080)

  if Err::isErr(err) {
    println(err.getMessage())
    return:
  }

  spawn ticker()

  for var i: int = 0 to 2 {
    var req: vortex::Request, res: vortex::Response = app.accept()
    println("  [server] " + req.getMethod() + " " + req.getPath())

    res.status(200)
       .header("X-Powered-By", "FlowWing")
       .send("hello from vortex\n")
  }

  println("  [server] served 3 requests")
}

fg_main()
real output
Vortex server listening on port 8080
  [server] GET /hello1
  [server] GET /hello2
  [server] GET /hello3
  [server] served 3 requests
  [ticker] still alive 0
  [ticker] still alive 1
  [ticker] still alive 2
  [ticker] still alive 3
  [ticker] still alive 4

The ticker keeps printing while accept() waits. A blocking server could not do that.

Response — json, header, streaming

Response json() header() streamBegin() streamWrite() streamEnd()
program
/; Every Response method: status, header, json, streamBegin/Write/End
bring vortex
bring Err
bring json
bring sys

fun fg_main() -> nthg {
  var app: vortex::Server = new vortex::Server()
  var err: Err::Result = app.listen(8190)
  if Err::isErr(err) { println(err.getMessage()) return: }

  for var i: int = 0 to 2 {
    var req: vortex::Request, res: vortex::Response = app.accept()
    var path: str = req.getPath()
    println("  " + req.getMethod() + " " + path + "  body=[" + req.getBody() + "]")

    if path == "/json" {
      var node: json::JsonNode = json::newObject()
      node.put("lang", json::newString("Flow-Wing"))
      node.put("version", json::newNumber(1.0))
      node.put("fast", json::newBool(true))
      res.status(200).json(node)
    } or if path == "/stream" {
      res.streamBegin("text/plain")
      for var k: int = 0 to 2 {
        res.streamWrite("chunk " + String(k) + "\n")
      }
      res.streamEnd()
    } else {
      res.status(200).header("X-Demo", "yes").send("plain body\n")
    }
  }
  println("  served 3")
}
fg_main()
real output
Vortex server listening on port 8190
  POST /plain  body=[hi]
  POST /json  body=[hi]
  POST /stream  body=[hi]
  served 3

--- what the client received ---
curl /plain   ->  plain body
curl /json    ->  {"lang": "Flow-Wing", "version": 1, "fast": true}
curl /stream  ->  chunk 0
                  chunk 1
                  chunk 2

json() sets Content-Type for you. streamBegin/Write/End sends chunked transfer encoding.

HTTP client — 5 requests, one thread

vortex::Client new() isOk() readChunk() isDone() close()
program
/; ===========================================================================
/; FEATURE 7 — the Vortex HTTP CLIENT, now libuv-native
/;
/; The response is parsed on the Flow-Wing thread inside the shared event
/; loop, so a request costs a socket and not a thread.
/;
/; So several requests overlap on ONE thread. This file fires 5 at once and
/; times them.
/;
/; Needs a server. Start 06_server.fg first, or point the URL anywhere.
/; ===========================================================================

bring vortex
bring sys

var replies: int = 0

fun fetch(id: int, t0: int64) -> nthg {
  var c: vortex::Client = new vortex::Client("http://127.0.0.1:8189/x", `{"id":1}`)

  var body: str = ""
  while !c.isDone() {
    var chunk: str = c.readChunk()
    if chunk != "" {
      body = body + chunk
    }
  }
  /; read the status BEFORE close(): close() frees the request
  var ok: bool = c.isOk()
  c.close()

  replies = replies + 1
  println("  request " + String(id) + " finished at "
          + String(Int64(sys::elapsedNanos(t0) / 1000000l)) + " ms  ok=" + String(ok)
          + "  body=" + String(body != ""))
}

var t0: int64 = sys::nanos()
for var i: int = 0 to 4 {
  spawn fetch(i, t0)
}

println("5 requests, all at once, one thread:")
real output
5 requests, all at once, one thread:
  request 0 finished at 302 ms  ok=true  body=true
  request 2 finished at 302 ms  ok=true  body=true
  request 3 finished at 302 ms  ok=true  body=true
  request 1 finished at 302 ms  ok=true  body=true
  request 4 finished at 302 ms  ok=true  body=true

Against a server that needs 300 ms per request. All five land at 302 ms, not 302/607/911/1212/1516. Read isOk() BEFORE close() — close() frees the request.

Concurrent server — spawn the handler

spawn handle(req, res)
program
/; ===========================================================================
/; FEATURE 6b — a CONCURRENT server: spawn the handler
/;
/; accept() returns one request at a time and your loop handles it. If the
/; handler is slow, the next accept() waits — requests queue up behind it.
/;
/; `spawn handle(req, res)` fixes that. The loop goes straight back to
/; accept(), and the handler runs as its own task. req and res are objects, so
/; the task receives POINTERS to the same instances.
/;
/; Same 300 ms of work per request, but now they overlap.
/; ===========================================================================

bring vortex
bring Err
bring sys

fun handle(req: vortex::Request, res: vortex::Response) -> nthg {
  sys::sleep(300)                 /; slow work: a DB call, an LLM, anything
  res.status(200).send("reply\n")
}

fun fg_main() -> nthg {
  var app: vortex::Server = new vortex::Server()
  var err: Err::Result = app.listen(8189)
  if Err::isErr(err) { println(err.getMessage()) return: }

  for var i: int = 0 to 4 {
    var req: vortex::Request, res: vortex::Response = app.accept()
    spawn handle(req, res)        /; <-- the whole difference
  }
}

fg_main()
real output
without spawn (sequential accept loop):
  request 0 finished at  302 ms
  request 1 finished at  607 ms
  request 2 finished at  911 ms
  request 3 finished at 1212 ms
  request 4 finished at 1516 ms

with spawn handle(req, res):
  request 0 finished at 302 ms
  request 2 finished at 302 ms
  request 3 finished at 302 ms
  request 1 finished at 302 ms
  request 4 finished at 302 ms

One keyword. 1516 ms -> 302 ms, same single thread. req and res are objects, so the task gets pointers to the same instances.

What spawn refuses

compile-time diagnostics
program
spawn 42

fun computes() -> int { return 7 }
spawn computes()

class Worker { fun run() -> nthg { println("x") } }
var w: Worker = new Worker()
spawn w.run()

spawn println("hello")

fun bump(inout n: int) -> nthg { n = n + 1 }
var c: int = 0
spawn bump(c)
real output
[Error:SpawnRequiresFunctionCall]  'spawn' expects a function call.
[Error:SpawnRequiresNthgReturn]  'spawn' needs a function that returns 'nthg'.
[Error:SpawnRequiresPlainFunction]  'spawn' cannot queue a method call.
[Error:SpawnRequiresUserFunction]  'spawn' cannot queue a built-in call.
[Error:SpawnByReferenceArgument]  'spawn' cannot pass an argument by reference.

Each is a compile error with its own name, so the message tells you what to change.

1 · spawn — the queue new keyword

A spawn does not call anything. It appends a record to a queue. The queue drains after the top-level body ends.

Press Step.
Your program

      
Ready queue (FIFO)
empty
Output

      
Why it is a queue and not a call

A task needs its own machine stack so it can stop half-way and continue later. Allocating that stack at the spawn would cost 256 KB per queued task — a million spawns would be 256 GB. The stack is taken on first resume and released on completion, so a queued task costs one small record.

Runnable program — spawn f()
program
/; ===========================================================================
/; FEATURE 1 — the `spawn` keyword
/;
/; `spawn f()` does NOT call f. It puts f on a queue. The queue is drained
/; after the top-level body finishes.
/; ===========================================================================

fun greet() -> nthg {
  println("  [task] hello from a task")
}

fun farewell() -> nthg {
  println("  [task] goodbye from a task")
}

println("1. before spawn")
spawn greet()
spawn farewell()
println("2. after spawn  <-- neither task has run yet")
println("3. top level ends here; the drain starts")
real output
1. before spawn
2. after spawn  <-- neither task has run yet
3. top level ends here; the drain starts
  [task] hello from a task
  [task] goodbye from a task

2 · The stack swap — how a task suspends

This is the heart of it. swapcontext on POSIX, Fibers on Windows.

Press Step.
main stack (8 MB)
task A stack (256 KB)
task B stack (256 KB)
CPU registers

      
What the C code does

      
The guard page added

The bottom page of every task stack is mapped PROT_NONE. Running off the end touches it and raises SIGSEGV, which a handler on a sigaltstack turns into a named error instead of a silent exit 139.

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)
Runnable program — sys::yield()
program
/; ===========================================================================
/; FEATURE 3 — sys::yield()  (cooperative switching)
/;
/; yield parks the task and puts it at the BACK of the queue. The task keeps
/; its own stack, so it resumes exactly where it stopped.
/; ===========================================================================

bring sys

fun worker(name: str) -> nthg {
  println("  " + name + " step 1")
  sys::yield()
  println("  " + name + " step 2")
  sys::yield()
  println("  " + name + " step 3")
}

spawn worker("A")
spawn worker("B")

println("watch A and B take turns:")
real output
watch A and B take turns:
  A step 1
  B step 1
  A step 2
  B step 2
  A step 3
  B step 3

3 · spawn f(a, b) — the argument block new

Arguments are evaluated now but used later. They need a home the collector can see.

Press Step.
Caller's variables
GC argument block (one per spawn site)
What the task sees when it finally runs

  
The rule, measured
ArgumentStored asCaller changes it after the spawn
int, deci, bool, charvalue copynot visible
strpointer copynot visible (rebinding the caller's variable)
arrayelement-by-element copynot visible — a snapshot
dynbox copy, tag includednot visible, even a re-type
object / classpointer copyVISIBLE — one shared instance
globalnot an argument at allVISIBLE — read when the task runs
inout—rejected at compile time
Runnable program — spawn f(a, b)
program
/; ===========================================================================
/; FEATURE 2 — spawn WITH ARGUMENTS
/;
/; Arguments are evaluated at the spawn site and copied into a GC-traced block.
/; What the task sees depends on the type:
/;   int / str / dyn / array  -> COPIED  (caller changes are invisible)
/;   object / class           -> POINTER (shared; changes visible both ways)
/;   global                   -> read when the task RUNS, not when it spawned
/; ===========================================================================

type Box = { n: int, label: str }

var g_counter: int = 1

fun show(n: int, s: str, arr: int[3], b: Box) -> nthg {
  println("  int   = " + String(n)          + "   (copied)")
  println("  str   = " + s                  + "   (copied)")
  println("  arr[0]= " + String(arr[0])     + "   (copied)")
  println("  box.n = " + String(b.n)        + " (SHARED - caller changed it)")
  println("  global= " + String(g_counter)  + " (read at run time)")
}

var n: int = 10
var s: str = "orig" + "inal"
var arr: int[3] = [7, 8, 9]
var box: Box = { n: 100, label: "b" }

spawn show(n, s, arr, box)

/; every one of these happens BEFORE the task runs
n = 999
s = "CHANGED"
arr[0] = 999
box.n = 999
g_counter = 999

println("caller changed everything; now the task runs:")
real output
caller changed everything; now the task runs:
  int   = 10   (copied)
  str   = original   (copied)
  arr[0]= 7   (copied)
  box.n = 999 (SHARED - caller changed it)
  global= 999 (read at run time)

4 · The collector and a sleeping task

A suspended task's roots live on a stack nobody is standing on. Without help they would be swept.

Press Step.
Root sources the collector walks
Heap
The three pieces that make it work
FunctionJob
fw_gc_set_aux_root_scannerscheduler registers itself as an extra root source
fw_gc_mark_shadow_chainmarks the frames on one parked task's stack
fw_gc_push_root_objectroots an argument block that no stack points at yet

FW_GC_STRESS=1 collects on every allocation. Every GcTests fixture runs under it automatically.

5 · libuv — waiting on a socket instead of a clock

On its own the scheduler can only wait on a deadline. That is enough for sleep and nothing else.

Press Step.
Scheduler
empty
Parked on I/O
none
libuv loop
The handshake
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 one call another thread may make. It wraps uv_async_send, the only thread-safe function in libuv.

Runnable program — sys::sleep(ms)
program
/; ===========================================================================
/; FEATURE 4 — sys::sleep() inside a task SUSPENDS, it does not block
/;
/; 5 tasks each wait 200 ms. If sleep blocked the thread this would take
/; 1000 ms. Because each task parks, the total is about 200 ms.
/; ===========================================================================

bring sys

var done: int = 0

fun waiter(id: int, start: int64) -> nthg {
  sys::sleep(200)
  done = done + 1
  println("  task " + String(id) + " woke at "
          + String(Int64(sys::elapsedNanos(start) / 1000000l)) + " ms")
}

fun reporter(start: int64) -> nthg {
  sys::sleep(400)
  println("  all " + String(done) + " finished. Serial would need 1000 ms.")
}

var t0: int64 = sys::nanos()
for var i: int = 0 to 4 {
  spawn waiter(i, t0)
}
spawn reporter(t0)

println("5 tasks, each sleeping 200ms:")
real output
5 tasks, each sleeping 200ms:
  task 0 woke at 205 ms
  task 1 woke at 205 ms
  task 2 woke at 205 ms
  task 3 woke at 205 ms
  task 4 woke at 205 ms
  all 5 finished. Serial would need 1000 ms.
Runnable program — sys::nanos() micros() millis() elapsedNanos()
program
/; ===========================================================================
/; FEATURE 5 — the monotonic clock
/;
/; sys::timestamp() is SECONDS. It cannot measure anything fast, and it jumps
/; when the system clock changes. These four are nanosecond-precision and
/; never go backwards.
/; ===========================================================================

bring sys

var t0: int64 = sys::nanos()

var sum: int64 = 0
for var i: int = 0 to 500000 {
  sum = sum + 1
}

println("  nanos()        = " + String(sys::nanos()))
println("  micros()       = " + String(sys::micros()))
println("  millis()       = " + String(sys::millis()))
println("  elapsedNanos() = " + String(sys::elapsedNanos(t0)) + " ns for 500k adds")
println("  same in ms     = " + String(Int64(sys::elapsedNanos(t0) / 1000000l)) + " ms")
real output
nanos()        = 1408735135411166
  micros()       = 1408735135473
  millis()       = 1408735135
  elapsedNanos() = 2967042 ns for 500k adds
  same in ms     = 2 ms

6 · HTTP server and client — before and after

Both moved off cpp-httplib onto the shared loop. Numbers below are from this machine.

Server
beforeafter
enginecpp-httplibuv_tcp_t + llhttp
100 connections100 threads1 thread
memory~51 MB of stacks~0
Client
beforeafter
per request1 std::thread0
handoffmutex + condvarnone needed
5 requests5 threads1 thread
Measured — 5 clients against a server that takes 300 ms per request

The one line that makes the server concurrent
for var i: int = 0 to 4 {
  var req: vortex::Request, res: vortex::Response = app.accept()
  spawn handle(req, res)      ← without spawn each request waits for the last
}

req and res are objects, so the task gets pointers to the same instances. Measured: 1516 ms → 302 ms.

Runnable program — vortex::Server listen() accept() · Request getMethod() getPath() getBody() · Response status() header() send()
program
/; ===========================================================================
/; FEATURE 6 — the Vortex HTTP SERVER, now libuv-native
/;
/; One uv_tcp_t per connection on the shared event loop — no thread per
/; connection.
/;
/; accept() parks the task when no request is ready, so other tasks keep
/; running while the server waits.
/;
/; Run it, then in another terminal:
/;   curl localhost:8080/hello
/; ===========================================================================

bring vortex
bring Err
bring sys

fun ticker() -> nthg {
  /; proves the process is NOT blocked while the server waits for a request
  for var i: int = 0 to 4 {
    sys::sleep(300)
    println("  [ticker] still alive " + String(i))
  }
}

fun fg_main() -> nthg {
  var app: vortex::Server = new vortex::Server()
  var err: Err::Result = app.listen(8080)

  if Err::isErr(err) {
    println(err.getMessage())
    return:
  }

  spawn ticker()

  for var i: int = 0 to 2 {
    var req: vortex::Request, res: vortex::Response = app.accept()
    println("  [server] " + req.getMethod() + " " + req.getPath())

    res.status(200)
       .header("X-Powered-By", "FlowWing")
       .send("hello from vortex\n")
  }

  println("  [server] served 3 requests")
}

fg_main()
real output
Vortex server listening on port 8080
  [server] GET /hello1
  [server] GET /hello2
  [server] GET /hello3
  [server] served 3 requests
  [ticker] still alive 0
  [ticker] still alive 1
  [ticker] still alive 2
  [ticker] still alive 3
  [ticker] still alive 4

The ticker keeps printing while accept() waits. A blocking server could not do that.

Runnable program — Response json() header() streamBegin() streamWrite() streamEnd()
program
/; Every Response method: status, header, json, streamBegin/Write/End
bring vortex
bring Err
bring json
bring sys

fun fg_main() -> nthg {
  var app: vortex::Server = new vortex::Server()
  var err: Err::Result = app.listen(8190)
  if Err::isErr(err) { println(err.getMessage()) return: }

  for var i: int = 0 to 2 {
    var req: vortex::Request, res: vortex::Response = app.accept()
    var path: str = req.getPath()
    println("  " + req.getMethod() + " " + path + "  body=[" + req.getBody() + "]")

    if path == "/json" {
      var node: json::JsonNode = json::newObject()
      node.put("lang", json::newString("Flow-Wing"))
      node.put("version", json::newNumber(1.0))
      node.put("fast", json::newBool(true))
      res.status(200).json(node)
    } or if path == "/stream" {
      res.streamBegin("text/plain")
      for var k: int = 0 to 2 {
        res.streamWrite("chunk " + String(k) + "\n")
      }
      res.streamEnd()
    } else {
      res.status(200).header("X-Demo", "yes").send("plain body\n")
    }
  }
  println("  served 3")
}
fg_main()
real output
Vortex server listening on port 8190
  POST /plain  body=[hi]
  POST /json  body=[hi]
  POST /stream  body=[hi]
  served 3

--- what the client received ---
curl /plain   ->  plain body
curl /json    ->  {"lang": "Flow-Wing", "version": 1, "fast": true}
curl /stream  ->  chunk 0
                  chunk 1
                  chunk 2

json() sets Content-Type for you. streamBegin/Write/End sends chunked transfer encoding.

Runnable program — vortex::Client new() isOk() readChunk() isDone() close()
program
/; ===========================================================================
/; FEATURE 7 — the Vortex HTTP CLIENT, now libuv-native
/;
/; The response is parsed on the Flow-Wing thread inside the shared event
/; loop, so a request costs a socket and not a thread.
/;
/; So several requests overlap on ONE thread. This file fires 5 at once and
/; times them.
/;
/; Needs a server. Start 06_server.fg first, or point the URL anywhere.
/; ===========================================================================

bring vortex
bring sys

var replies: int = 0

fun fetch(id: int, t0: int64) -> nthg {
  var c: vortex::Client = new vortex::Client("http://127.0.0.1:8189/x", `{"id":1}`)

  var body: str = ""
  while !c.isDone() {
    var chunk: str = c.readChunk()
    if chunk != "" {
      body = body + chunk
    }
  }
  /; read the status BEFORE close(): close() frees the request
  var ok: bool = c.isOk()
  c.close()

  replies = replies + 1
  println("  request " + String(id) + " finished at "
          + String(Int64(sys::elapsedNanos(t0) / 1000000l)) + " ms  ok=" + String(ok)
          + "  body=" + String(body != ""))
}

var t0: int64 = sys::nanos()
for var i: int = 0 to 4 {
  spawn fetch(i, t0)
}

println("5 requests, all at once, one thread:")
real output
5 requests, all at once, one thread:
  request 0 finished at 302 ms  ok=true  body=true
  request 2 finished at 302 ms  ok=true  body=true
  request 3 finished at 302 ms  ok=true  body=true
  request 1 finished at 302 ms  ok=true  body=true
  request 4 finished at 302 ms  ok=true  body=true

Against a server that needs 300 ms per request. All five land at 302 ms, not 302/607/911/1212/1516. Read isOk() BEFORE close() — close() frees the request.

Runnable program — spawn handle(req, res)
program
/; ===========================================================================
/; FEATURE 6b — a CONCURRENT server: spawn the handler
/;
/; accept() returns one request at a time and your loop handles it. If the
/; handler is slow, the next accept() waits — requests queue up behind it.
/;
/; `spawn handle(req, res)` fixes that. The loop goes straight back to
/; accept(), and the handler runs as its own task. req and res are objects, so
/; the task receives POINTERS to the same instances.
/;
/; Same 300 ms of work per request, but now they overlap.
/; ===========================================================================

bring vortex
bring Err
bring sys

fun handle(req: vortex::Request, res: vortex::Response) -> nthg {
  sys::sleep(300)                 /; slow work: a DB call, an LLM, anything
  res.status(200).send("reply\n")
}

fun fg_main() -> nthg {
  var app: vortex::Server = new vortex::Server()
  var err: Err::Result = app.listen(8189)
  if Err::isErr(err) { println(err.getMessage()) return: }

  for var i: int = 0 to 4 {
    var req: vortex::Request, res: vortex::Response = app.accept()
    spawn handle(req, res)        /; <-- the whole difference
  }
}

fg_main()
real output
without spawn (sequential accept loop):
  request 0 finished at  302 ms
  request 1 finished at  607 ms
  request 2 finished at  911 ms
  request 3 finished at 1212 ms
  request 4 finished at 1516 ms

with spawn handle(req, res):
  request 0 finished at 302 ms
  request 2 finished at 302 ms
  request 3 finished at 302 ms
  request 1 finished at 302 ms
  request 4 finished at 302 ms

One keyword. 1516 ms -> 302 ms, same single thread. req and res are objects, so the task gets pointers to the same instances.