NURL

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, unlock

The 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_at into and out of slices) by any number of threads without a shared position; pfile_datasync is 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
}
FunctionMeaning
runtime_init workersstart the worker pool
runtime_runblock until the pending-fiber count reaches 0
spawn bodyfire-and-forget fiber
spawn_joinable bodyfiber 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
yieldcooperative 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 )     → v

Channel 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_spawn and spawn closures.
  • Networking — the async-aware socket layer these primitives sit under.
  • For the full runtime design, see docs/ASYNC.md.

Last updated on

On this page