Concurrency
OS threads, the async fiber runtime, and channels.
NURL offers two concurrency models that share the same channel type: plain OS threads, and an M:N fiber runtime for I/O-heavy workloads. The same borrow-checker rules cover both to share state safely.
Threads
stdlib/std/thread.nu
wraps libpthread directly: thread_spawn, mutexes, condition variables,
semaphores, and Locked [T] for state threads share and change.
( thread_spawn ( @ v ) f ) → !Thread ThreadErr
( thread_join Thread t ) → i
( mutex_new ) → Mutex
( mutex_with Mutex m ( @ v ) body ) → v // lock, run, unlockThe runtime copies a thread_spawn closure's captured environment to the worker
thread (a closure written in the call itself is handed over instead). The compiler rejects capturing an Rc (a non-atomic reference count) at
compile time. If two threads race on its count, the result is undefined behavior. Use
Arc (stdlib/std/arc.nu, atomic reference count) for any read-only handle that you share
across threads. An Arc's payload is frozen once made; state that threads
share and mutate lives in a ( Locked T ) (stdlib/std/thread.nu), which pairs an atomic reference count with a mutex:
: ( Locked i ) shared ( locked_new [i] 0 )
: !Thread ThreadErr t ( thread_spawn \ → v {
( locked_with [i] shared \ inout i val → v { = val + val 1 } )
} )
( thread_join ( res_unwrap t ) )locked_await waits on a Locked value until a closure run on it says it
is done (a monitor's wait): the lock is let go between runs, and
locked_update, locked_apply and locked_set wake the waiters.
Atomics, reader-writer locks, positional files
Three more shared handles cover what a mutex round trip serves badly. Every copy of one is the same object, so a thread closure captures it and the last copy releases it:
Atomic(stdlib/std/atomic.nu) — a word changed only by atomic instructions:atomic_load,atomic_store,atomic_add,atomic_swap,atomic_cas,atomic_max.RwLocked [T](stdlib/std/rwlock.nu) — many readers or one writer. A read guard (rw_read) lends a frozen view — handing it to anything that would change it is a compile error — and a write guard (rw_write) a changeable one; both are released by their drop, through a panic too. A thread may take a second read guard of a lock it already reads; asking for one it holds for writing panics instead of waiting for itself.PFile(stdlib/std/pfile.nu) — a file read and written at offsets (pfile_read_at,pfile_write_atinto and out of slices) by any number of threads without a shared position;pfile_datasyncis a write-ahead log's commit barrier.
: Atomic hits ( atomic_new 0 )
: ( RwLocked ( Vec i ) ) table ( rw_new [( Vec i )] ( vec_new [i] ) )
( rw_update [( Vec i )] table \ inout ( Vec i ) v → v { ( vec_push [i] v 42 ) } )
: !Thread ThreadErr t ( thread_spawn \ → v {
: ( RGuard ( Vec i ) ) g ( rw_read [( Vec i )] table )
( atomic_add hits ( vec_len [i] ( rguard_get [( Vec i )] g ) ) )
} )
( thread_join ( res_unwrap t ) )Async fibers
stdlib/std/async.nu
ships an M:N stackful-fiber runtime: many lightweight fibers multiplex
onto a small pool of OS worker threads, with an I/O reactor that parks a
fiber on a socket and resumes it when the socket is ready.
There is no async keyword and no colored functions — ordinary code runs
unchanged inside a fiber, and blocking I/O calls become cooperative
automatically when they run in a fiber context.
$ `stdlib/std/async.nu`
@ main → i {
( runtime_init 0 ) // 0 = worker count from $NURL_WORKERS, default cores
( spawn \ → v { ( nurl_print `hello from a fiber\n` ) } )
( runtime_run ) // blocks until every fiber is done
^ 0
}| Function | Meaning |
|---|---|
runtime_init workers | start the worker pool |
runtime_run | block until the pending-fiber count reaches 0 |
spawn body | fire-and-forget fiber |
spawn_joinable body | fiber whose completion can be awaited with fiber_join; fiber_join takes the handle (a Fiber is move-only), and a handle dropped unjoined detaches the fiber |
yield | cooperative reschedule point |
stdlib/runtime_ctx.c implements inline-assembly fiber context switching on x86_64 (Linux glibc and musl, macOS, FreeBSD), AArch64 (Linux and macOS ARM64), and RISC-V64 (including freestanding unikernels), falling back to POSIX ucontext on other Unix targets. On stubbed targets (Windows, OpenBSD, and WASI), fibers are not supported and spawn is a no-op stub — treat stubbed platforms as having no async runtime.
Spawn is not a new thread
A spawned closure runs on some worker's fiber. A CPU-bound fiber that
never yields occupies its worker until it does — call yield inside long
computations to keep other fibers responsive.
Channels
stdlib/std/channel.nu
provides Channel[A], one generic thread-safe FIFO queue that works from
both plain threads and fibers — a fiber caller parks without blocking its
worker; a thread caller blocks on a condition variable.
( chan_new [A] ) → ( Channel A )
( chan_send [A] ( Channel A ) ch A v ) → b // false if closed
( chan_recv [A] ( Channel A ) ch ) → ?A // None when closed and drained
( chan_close [A] ( Channel A ) ch ) → vChannel select
A ?? that has { immediately after it — with no scrutinee — is a Go-style
select: it runs the body of whichever channel arm becomes ready first, in
source order.
?? {
[i] jobs → o { ?? o { T n → ( work n ) F → ( quit ) } }
[String] control → o { ?? o { T s → ( handle s ) F → ( quit ) } }
_ → { /* nothing ready */ }
}With no _ arm, the select blocks until some channel is ready. With a
_ arm, it never blocks. The default runs immediately when nothing is ready.
Next
- Memory and ownership — the escape-analysis rules that apply to
thread_spawnandspawnclosures. - Networking — the async-aware socket layer these primitives sit under.
- For the full runtime design, see
docs/ASYNC.md.
Last updated on