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.
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
/; ===========================================================================
/; 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")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
/; ===========================================================================
/; 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:")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
/; ===========================================================================
/; 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:")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
/; ===========================================================================
/; 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:")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
/; ===========================================================================
/; 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")nanos() = 1408735135411166 micros() = 1408735135473 millis() = 1408735135 elapsedNanos() = 2967042 ns for 500k adds same in ms = 2 ms
task stacks, default size
/; ===========================================================================
/; 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:")one deep task + 300 shallow tasks: recursion reached depth 2001
task stack overflow
/; ===========================================================================
/; 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:")[91mRuntime 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.[0m
the null literal and map.get()
/; ===========================================================================
/; `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)))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
/; ===========================================================================
/; 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()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
/; 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()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 2json() sets Content-Type for you. streamBegin/Write/End sends chunked transfer encoding.
HTTP client — 5 requests, one thread
/; ===========================================================================
/; 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:")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
/; ===========================================================================
/; 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()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
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)[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.
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.
/; ===========================================================================
/; 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")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.
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)
/; ===========================================================================
/; 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:")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.
| Argument | Stored as | Caller changes it after the spawn |
|---|---|---|
| int, deci, bool, char | value copy | not visible |
| str | pointer copy | not visible (rebinding the caller's variable) |
| array | element-by-element copy | not visible — a snapshot |
| dyn | box copy, tag included | not visible, even a re-type |
| object / class | pointer copy | VISIBLE — one shared instance |
| global | not an argument at all | VISIBLE — read when the task runs |
| inout | — | rejected at compile time |
/; ===========================================================================
/; 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:")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.
| Function | Job |
|---|---|
| fw_gc_set_aux_root_scanner | scheduler registers itself as an extra root source |
| fw_gc_mark_shadow_chain | marks the frames on one parked task's stack |
| fw_gc_push_root_object | roots 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.
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.
/; ===========================================================================
/; 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:")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.
/; ===========================================================================
/; 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")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.
| before | after | |
|---|---|---|
| engine | cpp-httplib | uv_tcp_t + llhttp |
| 100 connections | 100 threads | 1 thread |
| memory | ~51 MB of stacks | ~0 |
| before | after | |
|---|---|---|
| per request | 1 std::thread | 0 |
| handoff | mutex + condvar | none needed |
| 5 requests | 5 threads | 1 thread |
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.
/; ===========================================================================
/; 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()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.
/; 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()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 2json() sets Content-Type for you. streamBegin/Write/End sends chunked transfer encoding.
/; ===========================================================================
/; 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:")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.
/; ===========================================================================
/; 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()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.