Nimbus implements wasi-threads. pthread programs — mutexes, condition
variables, pthread_join, thread-local storage, barriers, semaphores,
pthread_once — run correctly.
They do not run in parallel. One core, one thread at a time. A CPU-bound program with four threads finishes with the right answer and no faster than the single-threaded version, plus context-switch overhead.
A Nimbus process is one isolate, and threads of one process never leave it, so they need no cross-isolate primitive:
- One
WebAssembly.Memory, createdshared: trueby the host. - One module, compiled when the facet loads and instantiated once per thread.
Every instance addresses the same linear memory and gets its own globals,
which is where each thread's
__stack_pointerand__tls_baselive. TLS needs no host emulation.wasi_thread_startsets both from the start struct the spawning thread allocated. - One cooperative scheduler: round-robin, run-to-park, creation order, exactly one thread executing at a time. Memory is shared, and access to it is serialised, so results stay correct.
- One software futex.
Atomics.waitthrows on Workers and thememory.atomic.wait32instruction traps for the same reason. Blocking is therefore a host park. The waiter registers a predicate (*addr != val) and yields. There is no wake call. Every scheduling pass re-tests every waiter, so the futex is level-triggered and a lost wakeup is not expressible.
Level-triggering also makes deadlock detection exact. If every live thread is parked on a predicate, none holds, and no host I/O or timer is outstanding, then no thread can run. Memory can never change, so no predicate can ever become true. Nimbus reports it and stops:
deadlock: every thread is blocked and nothing can wake them (tids 1, 2)
The implementation is packages/worker/src/runtime/wasi-threads.ts.
runtime/ruby-green-threads.ts runs the same model inside the Ruby VM over
fibers.
clang --target=wasm32-wasip1-threads --sysroot=<wasi-sysroot> -pthread -O2 \
-Wl,--import-memory,--shared-memory,--max-memory=67108864 \
-o prog.wasm prog.c packages/worker/runtime-contracts/nimbus-threads.c
Three requirements:
-
--import-memory --shared-memory. The host creates the memory so every thread instance binds to the same one. A module that defines its own memory would give each thread a private address space. -
wasi_thread_startexported. The wasi-threads entry point;-pthreademits it. -
nimbus-threads.clinked, against wasi-sdk 27 or newer. wasi-libc compiles its futex wait tomemory.atomic.wait32. That instruction traps on Workers with "Atomics.wait cannot be called in this context" the first time a lock is contended. wasi-libc calls the weak symbol__wasilibc_futex_wait_maybe_busyinstead whenever something defines it; that file defines it, routing every blocking pthread operation to the host futex. The hook is wasi-libc's own, so there is no patched libc and no binary rewriting.Ignore the version floor and the build fails quietly. A wasi-libc without that hook never calls the definition, so the linker drops it as unreachable and the build succeeds with no warning. The binary looks correctly built and is refused at load. Measured: wasi-sdk 25 ships no hook, wasi-sdk 27 does.
A build missing any of these is rejected at load, with the build line in the error.
- No parallelism. The threads of one process share one
WebAssembly.Memory, so they must run in one isolate. Peer Durable Objects do get independent CPU, but a process cannot spread its own threads across them. Resident processes also default to facets, whose siblings share CPU. - No preemption yet. A thread that never reaches a blocking operation (a
bare spin loop, a tight compute loop) holds the process until it does. A
spinlock that calls
sched_yield()is fine; one that does not will hang. Nimbus controls the module build, so back-edge yield fuel can fix this, and that is the next step. - Timing-dependent code behaves differently. Execution is serialised, so interleavings a real scheduler would produce do not occur. Code whose correctness depends on a data race is undefined behaviour under pthreads anyway.
- 128 threads. Each one is an instance plus a stack in shared memory. Past
the cap
pthread_createreturnsEAGAIN. - Emscripten pthreads are not supported. That model needs a Web Worker pool
and
Atomics.wait, neither of which exists here. Python (Pyodide) is an Emscripten build, so this does not give Python threads.
Measured in a Durable Object at compatibility date 2026-04-01:
Two instances of one module, one shared WebAssembly.Memory |
Writes and atomic RMWs visible across instances; globals independent per instance |
| Distinct JSPI-suspended instances resumed round-robin | Trace [0,1,2,0,1,2,…] — a scheduler, not run-to-completion |
Atomics.load/store/add/compareExchange/notify, waitAsync |
Work |
Atomics.wait, memory.atomic.wait32 |
Throw — hence the software futex |
WebAssembly.compile(bytes) at request time |
Blocked — hence one startup-compiled module, instantiated per thread |
new WebAssembly.Instance(module) at request time |
Allowed |