Skip to content

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.

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

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 finish
print("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.

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 internally

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

  • Pass small values. thread_create takes a single arg. For more context, pack it into a arrayJson and 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.

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; // 42
await 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.

  • async func compiles 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 next await or at program exit).
  • await expr — if expr is 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. await on a plain value is the identity (await 5 == 5).
  • sleep_async(ms) returns a promise that fulfils after ms milliseconds without blocking — unlike sleep(ms), which blocks. Use await 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