Concurrency (Threads & Mutexes)
Tulpar exposes OS threads directly through three built-in functions:
thread_create, mutex_create, and the mutex_lock / mutex_unlock
pair. These are real OS threads — each call to thread_create spawns
one, so cooperation with locks is your responsibility. For cooperative,
single-threaded concurrency (non-blocking timers and HTTP), reach for
Async / Await instead.
Spawning a thread
Section titled “Spawning a thread”thread_create(handler_name, arg) starts a new thread that calls the
function named by the first argument, passing it the second argument
as a single value. The return is a thread id (int); pair it with
thread_detach(id) if you do not plan to join.
func worker(arg) { print("Worker running with arg =", arg);}
int t = thread_create("worker", 42);thread_detach(t);The handler is looked up by name (string) the first time it runs
and cached, so it must be a top-level function the AOT pipeline
exported (the default for any func declaration).
Protecting shared state
Section titled “Protecting shared state”A counter mutated from multiple threads is the canonical race. Wrap
the read-modify-write region in mutex_lock / mutex_unlock:
int counter = 0;int mu = mutex_create();
func bump(arg) { mutex_lock(mu); counter = counter + 1; mutex_unlock(mu);}
for (int i = 0; i < 100; i++) { int t = thread_create("bump", i); thread_detach(t);}
sleep(100); // give the threads a moment to finishprint("counter =", counter);Hold the lock for the shortest region you can — typically only the
write to shared state. The HTTP server in lib/wings.tpr uses exactly
this pattern: it locks around handler dispatch and unlocks before the
response is built and sent.
A worked example: threaded HTTP server
Section titled “A worked example: threaded HTTP server”lib/wings.tpr (the Wings framework) accepts new TCP connections in
its main loop, then hands each connection to a worker thread. The core
idiom looks like this:
import "wings";
func handle_root() { return wings_text("hello from " + toString(thread_id()));}
get("/", "handle_root");listen(8080); // wings spawns one thread per connection internallySee HTTP Server (Wings) for the full
framework — the threading happens inside listen, you don’t have to
wire it up yourself for the common case.
Rules of thumb
Section titled “Rules of thumb”- Pass small values.
thread_createtakes a single arg. For more context, pack it into aarrayJsonand unbox in the worker. - Lock around shared writes only. Reads of immutable data don’t need a lock; use one mutex per resource, not one global lock.
- Detach if you don’t join. A leaked thread handle is a real OS thread that will never be reclaimed.
- Sleep is not synchronization.
sleep(ms)is for pacing, not for waiting on a condition. Use a mutex + a flag the worker sets.
Async / await (cooperative coroutines)
Section titled “Async / await (cooperative coroutines)”Separate from OS threads, Tulpar has async / await for cooperative
concurrency on a single thread (added in v3.0.0). An async func returns a
promise immediately; await suspends the caller until that promise
settles, letting other coroutines run in the meantime.
async func slow_double(int n) { await sleep_async(20); // non-blocking pause — other tasks run return n * 2;}
async func greet(str who) { await sleep_async(10); print("hello " + who); return 1;}
// Spawn two coroutines; they run concurrently while we await.var a = slow_double(21);var g = greet("world");
int result = await a; // 42await g;print("done");greet (10 ms timer) finishes before slow_double (20 ms) even though
slow_double was spawned and awaited first — proof the two coroutines
interleave rather than block.
How it works
Section titled “How it works”async funccompiles to a coroutine. Calling it spawns the task and returns a pending promise; the body does not run until the event loop pumps it (on the nextawaitor at program exit).await expr— ifexpris a promise, suspend until it settles and yield its value; inside a coroutine this hands control to the scheduler, on the main thread it drives the loop until ready.awaiton a plain value is the identity (await 5 == 5).sleep_async(ms)returns a promise that fulfils aftermsmilliseconds without blocking — unlikesleep(ms), which blocks. Useawait sleep_async(ms)for non-blocking delays.- The runtime drains all pending tasks at program exit, so a spawned-but-unawaited coroutine still completes (Node-style).
A coroutine can await another coroutine, so you can compose async functions:
async func inc(int x) { await sleep_async(2); return x + 1; }async func chain(int x) { int a = await inc(x); int b = await inc(a); return b;}print(await chain(40)); // 42