languages/nim/concurrency.md

Nim Concurrency: Threads, Channels, and the asyncdispatch/chronos Split (2026)

Verified guidance on Nim threading primitives (std/typedthreads, std/locks, channels), ARC/ORC's shared-heap thread model, taskpools/weave versus the deprecated std/threadpool, and the critical, non-interoperable split between std/asyncdispatch and chronos.

Introduction / Overview

Nim's concurrency story is split along two independent axes that must not be conflated: parallelism (raw OS threads, thread pools, and data-parallel libraries, for CPU-bound work spread across cores) and asynchronous I/O (single-threaded, cooperatively-scheduled async/await for high-concurrency I/O-bound work). Both axes carry traps that are unusually easy for a code-generating LLM to fall into, because Nim's defaults changed materially at the Nim 2.0 release (August 2023) and because the async ecosystem is genuinely, permanently split between two incompatible frameworks rather than converging on one.

This document is verified against the current (2.2.x) Nim manual, the nim-lang/threading and status-im/nim-taskpools/status-im/nim-chronos repositories, and the Nim 2.0 release notes. Where the ecosystem is still in flux (e.g., exact adoption levels of taskpools versus weave), this is flagged explicitly rather than presented as settled fact.

When to use this guidance: Writing any Nim code that spawns a thread, shares data between threads, or uses async/await - and, most importantly, before adding any async-touching dependency to a Nim project, to confirm which async ecosystem it commits you to.

Core Concepts

  • --threads:on is the default since Nim 2.0: Prior to Nim 2.0, multi-threading support was opt-in and had to be requested explicitly with the --threads:on compiler flag; the RFC to flip this default (nim-lang/RFCs#361) was accepted for the 2.0 release, and the Nim 2.0.2 release notes confirm --threads:on as "the default." You may still see --threads:on written explicitly in older tutorials, CI scripts, and Nimble tasks - it is harmless but no longer necessary on Nim >= 2.0. --threads:off still exists and can be passed explicitly to disable OS-thread support entirely (smaller binaries, no Thread[T]/channel support), but this is rare in modern code and several stdlib and third-party modules assume threading is available.
  • ARC/ORC use a shared heap across threads, unlike the old refc GC: Under the legacy --mm:refc garbage collector (Nim 1.x's default), each thread owned its own independent heap, and passing a ref across a thread boundary required deep-copying data into that thread's heap. Under ARC/ORC (the default since 2.0), all threads share one address space and one heap - a ref, seq, or string can be moved to another thread with no copy at all. This is a genuine improvement for performance, but it removes a safety net: ARC/ORC's reference-count increments/decrements are not atomic. Two threads simultaneously holding and mutating the same ref will race on its hidden refcount field, corrupting memory - Nim avoids this not by making refcounts atomic (slow) but by requiring that shared data be moved (transferring sole ownership) rather than aliased (copied while multiple owners remain live) across a thread boundary.
  • Isolated[T] (std/isolation) is the compiler-checked mechanism for safe cross-thread transfer: isolate(value) produces an Isolated[T] only when the compiler can prove no other alias to value's subgraph remains reachable; extract(isolatedValue) moves the value back out on the receiving side. This is the primitive that channel implementations built for ARC/ORC use to make "send a ref to another thread" provably safe rather than merely conventionally safe.
  • {.gcsafe.} asserts a proc touches no globally-shared GC'd memory unsafely: A gcsafe proc does not read or write any global variable holding GC'd memory (ref, string, seq, closures) - directly, or indirectly via calling a non-gcsafe proc - except through the isolation/move discipline above. This is what prevents two threads from racing on a shared global's refcount. createThread requires its thread-body proc to satisfy this discipline (enforced via the {.thread.} pragma on the proc type).
  • Async is a second, orthogonal concurrency model: async/await in both std/asyncdispatch and chronos runs on a single OS thread by default, using cooperative scheduling around an event loop (epoll/kqueue/select/IOCP depending on platform) - it gives you high I/O concurrency without OS-thread overhead, but zero CPU parallelism unless combined with spawn/taskpools/weave for the CPU-bound portions of the work.

Best Practices

1. Use std/typedthreads for raw OS threads

Thread[T]/createThread/joinThread/joinThreads live in std/typedthreads in current Nim. Historically (pre-2.0, and under older system-module-based code you may still see in tutorials), these symbols were available directly from system without any import once compiled with --threads:on. As the standard library has moved toward a "slimmer" system module, explicit import of std/typedthreads is the current, forward-compatible form and should always be used in new code.

import std/typedthreads
import std/locks

var
  counterLock: Lock
  sharedCounter: int

proc worker(id: int) {.thread.} =
  for _ in 0 ..< 1000:
    withLock counterLock:
      inc sharedCounter

initLock(counterLock)

var threads: array[4, Thread[int]]
for i in 0 ..< threads.len:
  createThread(threads[i], worker, i)

joinThreads(threads)
deinitLock(counterLock)
echo sharedCounter

The proc passed to createThread must have the {.thread.} pragma and must be gcsafe - it cannot close over arbitrary GC'd data from the spawning thread without going through an isolation-safe path (a ptr, a channel, or an Isolated[T]).

Rationale: std/typedthreads is the lowest common denominator underneath every higher-level library on this page - understanding it directly is necessary to reason correctly about what a pool library or channel abstracts away, and it remains the right tool for a small, fixed number of long-lived dedicated worker threads (e.g., one background thread per hardware sensor, one per persistent outbound connection) where a pool's task-queue abstraction adds nothing.

Why this works well in production: joining every spawned thread explicitly (joinThreads) before the enclosing scope exits guarantees no thread outlives the data it was given a ptr/Isolated[T] reference into - the same structured-lifetime discipline that taskpools' shutdown() and Rust's JoinSet (see Rust Best Practices) enforce in their own ecosystems.

2. Use std/locks for mutual exclusion, and prefer withLock over manual acquire/release

Rationale: Lock (a thin wrapper over the OS mutex), initLock/deinitLock, acquire/release, and the withLock(lock): body template are Nim's primitive synchronization tools. withLock releases the lock on the template's finally-equivalent path, so an exception raised inside the protected body cannot leave the lock permanently held - a manual acquire/release pair without a try/finally around it can. There is no built-in condition variable beyond Cond, which pairs with a Lock for wait/signal/notify patterns when a thread must block until another thread changes some shared condition rather than merely mutual-exclude access to it.

import std/locks

var
  queueLock: Lock
  queueNotEmpty: Cond
  pending: seq[int]

initLock(queueLock)
initCond(queueNotEmpty)

proc pushWork(item: int) =
  withLock queueLock:
    pending.add(item)
    signal(queueNotEmpty)

proc popWork(): int =
  withLock queueLock:
    while pending.len == 0:
      wait(queueNotEmpty, queueLock)
    result = pending.pop()

Why this works well in production: wait(cond, lock) atomically releases the lock while blocking and reacquires it before returning, which is the standard, deadlock-free way to implement a bounded producer/consumer queue without busy-waiting.

3. Choose the correct channel type for the ARC/ORC era

Rationale: Nim's channel story has two live implementations and an LLM must not conflate them - picking the wrong one, or assuming the built-in type has the newer type's isolation guarantees, is a common source of subtly wrong generated code.

  1. The built-in Channel[T], part of the system module's threading support (compiled in whenever --threads:on is active; historically documented as system/channels_builtin). It provides open/close, blocking send/recv, and non-blocking trySend/tryRecv, bounded by an optional maxItems (0 = unbounded). Its own documentation notes it is unstable when combined with spawn/std/threadpool, and a Channel instance itself cannot be copied or passed by value between threads - you must share it via a ptr or a global.
   import std/typedthreads

   var chan: Channel[int]
   chan.open()

   proc producer() {.thread.} =
     for i in 1 .. 5:
       chan.send(i)

   var t: Thread[void]
   createThread(t, producer)

   for _ in 1 .. 5:
     echo chan.recv()

   joinThread(t)
   chan.close()
  1. threading/channels (Chan[T]), from the separate, actively developed nim-lang/threading Nimble package. This is the modern, ARC/ORC-native multi-producer/multi-consumer channel, designed from the outset to move Isolated[T] data safely, rather than relying on the older system channel's implicit conventions. It requires adding threading as a Nimble dependency - it is not part of the bundled standard library.
   # nimble install threading
   import threading/channels
   import std/typedthreads
   import std/isolation

   var chan = newChan[string]()

   proc producer() {.thread.} =
     for msg in ["one", "two", "three"]:
       chan.send(isolate(msg))

   var t: Thread[void]
   createThread(t, producer)

   for _ in 1 .. 3:
     var received: string
     chan.recv(received)
     echo received

   joinThread(t)

Verification note: exact Chan[T] API signatures (newChan, send, recv, trySend, tryRecv) come from the nim-lang/threading GitHub repository's threading/channels module documentation; treat minor signature details (e.g., whether send takes the value directly or requires an explicit isolate() call at the call site) as subject to change across threading package releases, and check the installed version's own generated docs (nimble path threading) before relying on exact argument order in production code.

Guidance: for new code on Nim >= 2.0, prefer threading/channels' Chan[T] when the project can take the extra Nimble dependency; fall back to the built-in Channel[T] only for small scripts where avoiding an extra dependency matters more than the newer type's ARC/ORC-native isolation guarantees.

4. Prefer taskpools/weave over the deprecated std/threadpool for pooled task parallelism

Rationale: std/threadpool's spawn/sync API suffered from global-queue contention and no work-stealing; independent benchmarks cited in its own deprecation discussion showed programs compiled with --threads:on running roughly 40% slower even at a single thread of concurrency due to the module's inherent overhead. Its own deprecation message explicitly redirects users to malebolgia, taskpools, or weave.

LibraryStatusModelUse when
std/threadpool (spawn/sync)Deprecated. The module's own deprecation message directs users to malebolgia, taskpools, or weave instead.Global work queue, no work-stealingNever for new code - kept only for legacy compatibility
taskpools (status-im/nim-taskpools)Actively maintained, lightweight, ARC/ORC-nativeWork-stealing thread pool; Taskpool.new(numThreads), tp.spawn expr returns a Flowvar[T], sync(flowvar) blocks for the result, syncAll()/shutdown()Default choice for general task-parallel workloads (fire off N independent units of work, collect results)
weave (mratsim/weave)Actively maintained, HPC-orientedNestable data-parallelism and task-parallelism runtime with a work-stealing scheduler, designed for fine-grained tasks (matrix/tensor operations, tree/graph traversal) at large core countsNumerically intensive, deeply nested parallel workloads where taskpools' simpler model is insufficient
malebolgiaNewer, lighter-weight structured-concurrency-style library, mentioned directly in threadpool's own deprecation message as an alternativeStructured spawn/await over a thread poolVerify current maturity directly against its repository before adopting - it is referenced here because it is the stdlib's own suggested replacement, not because this document has exhaustively audited its API stability
# taskpools: the general-purpose default
import taskpools

proc computeChunk(id: int): int =
  id * id

var tp = Taskpool.new(numThreads = 4)
var results: array[4, Flowvar[int]]
for i in 0 ..< 4:
  results[i] = tp.spawn computeChunk(i)

for i in 0 ..< 4:
  echo sync(results[i])

tp.shutdown()

Raw threads vs. a pool library: reach for std/typedthreads directly only when you need long-lived, independently-scheduled workers (e.g., a dedicated background thread pumping a queue for the process's lifetime). For "run N independent units of work and collect results," a pool (taskpools first, weave for HPC-shaped workloads) removes the boilerplate of manual thread creation/joining and gives you work-stealing load balancing for free.

5. Pick exactly one async ecosystem per project - std/asyncdispatch or chronos, never both

This is the single highest-value piece of guidance in this document. Nim has two actively used, mutually incompatible async ecosystems, and mixing them in one codebase does not fail gracefully.

std/asyncdispatch: Bundled with every Nim installation. Provides Future[T], the async macro (transforms a proc into a state machine driven by a single-threaded event loop), await, and waitFor. Its event loop dispatches via the platform's native I/O multiplexer (epoll on Linux, kqueue on BSD/macOS, IOCP on Windows). It is the foundation for stdlib modules std/asyncnet, std/asynchttpserver, and the async variant of std/httpclient, and for community libraries and frameworks that predate or intentionally avoid the chronos dependency (e.g., jester, which can also run synchronously via httpbeast).

chronos: A separate package (status-im/nim-chronos), not part of the standard library, actively maintained by the Status/Nimbus (Ethereum client) organization. Verified, documented differences from asyncdispatch:

  • Its own Future[T] type - a distinct generic type from a different module, not a re-export or subtype of asyncdispatch.Future[T].
  • A unified callback signature (CallbackFunc = proc (arg: pointer = nil)), versus asyncdispatch's several distinct callback shapes (proc() for callSoon, proc(fut: Future[T]) for completion handlers).
  • Forward-order callback scheduling, versus asyncdispatch's reverse-order processing of completion callbacks - code that depends on callback ordering behaves differently under the two frameworks even where the surface syntax looks identical.
  • Exception-effect ({.raises.}) tracking integrated into its async macro, which asyncdispatch does not provide.
  • Its own I/O stack: transports, an HTTP server/client with built-in TLS (no OpenSSL dependency required), WebSocket support - none of which route through asyncdispatch's event loop.
  • Origin: chronos grew out of dissatisfaction with asyncdispatch's behavior and performance under Status's demanding peer-to-peer networking workloads (documented in the long-running nim-lang/RFCs discussion on "Multiple async implementations"); by its v4 release it had diverged far enough (own Future type, its own exception-tracking model) that it is now correctly understood as a second, independent async framework rather than a drop-in-compatible fork.

Notable chronos-dependent projects/libraries: libp2p (the peer-to-peer networking stack used by Nimbus and other Ethereum clients), presto (a REST API framework), nim-websock, and scorper (a chronos-based HTTP framework offered as an alternative to asyncdispatch-based web stacks).

Why mixing them fails, concretely:

Future[T] from chronos and Future[T] from std/asyncdispatch are two distinct generic types the Nim compiler has no relationship between. If you write an async proc using import asyncdispatch and call into a library whose own async procs are built on import chronos, you will get one of two symptoms, never a clean bridge:

  • A compile-time type mismatch when the two Future[T] types meet at a call boundary - e.g. attempting to await a chronos-returned Future[string] inside an asyncdispatch-flavored async proc, or passing one where the other is expected, produces an ordinary "type mismatch" error citing two apparently identical-looking but module-distinct Future[string] types.
  • A silent hang, in the rarer case where generic code accepts either Future[T] structurally (e.g., inside a template) - because each framework runs its own independent global event loop/dispatcher, a Future created by chronos's I/O primitives will never be driven to completion by asyncdispatch's waitFor/poll(), and vice versa. The program blocks forever waiting on a future that the running loop has no way to advance.

There is no supported compatibility shim as of this writing; a proposed pattern (a compile-time flag such as -d:chronosAsync for libraries wanting to support both) has been discussed in the ecosystem but is not a settled, universally adopted standard - verify the current state directly against the library in question rather than assuming such a flag exists.

The guidance, concretely:

  1. Decide up front: std/asyncdispatch (zero extra dependency, adequate for most application-level I/O) or chronos (required if you depend on libp2p, presto, or anything in the Status/Nimbus ecosystem, or if you need its stronger effect-tracking and I/O feature set).
  2. Before adding any Nimble dependency that does networking, HTTP, or async I/O, check which framework its own async procs are built on - Nimble does not model or enforce this constraint, and it is entirely possible to nimble install two libraries that silently commit you to both ecosystems.
  3. Never import asyncdispatch and import chronos (or a library built on the other) in the same binary's async call graph. Isolating a legacy component that used the other stack behind a synchronous (blocking) interface, run in its own thread, is the only currently workable interop pattern if a hard migration is not feasible.

6. Treat {.gcsafe.} as a real safety proof, not a formality to silence a warning

Rationale: {.gcsafe.} (inferred automatically for many procs, or asserted explicitly) is the compiler's guarantee that a proc does not read or write any global holding GC'd memory - directly or via a call chain - in a way that could race with another thread's access to that same global. It exists specifically because ARC/ORC's refcount operations are not atomic; two threads incrementing/decrementing the same object's hidden refcount concurrently is a memory-corrupting race, not merely a logic bug.

import std/typedthreads

var sharedLog: seq[string] = @[]

proc unsafeAppend(msg: string) =
  sharedLog.add(msg)          # touches a GC'd global -- NOT gcsafe

proc worker() {.thread.} =
  # The following line fails to compile: unsafeAppend is not gcsafe,
  # and the compiler will not silently let a thread proc call it.
  # unsafeAppend("from worker")
  discard

The correct fix is never to reach for {.cast(gcsafe).} to silence this without first restructuring the data flow - route the message through a channel (Chan[string]) or a Lock-protected global instead, so the compiler's rejection reflects a real, now-resolved hazard rather than a suppressed one.

Why this works well in production: a gcsafe violation caught at compile time is a five-minute fix (add a channel or a lock); the same bug, if bypassed with an unchecked cast, becomes a heisenbug that reproduces only under load, on specific hardware, or under a thread sanitizer - exactly the class of defect this pragma exists to make impossible to ship silently.

Common Pitfalls & Anti-Patterns

Pitfall: Mixing asyncdispatch and chronos libraries in one project

Problem: A dependency graph that pulls in one library built on std/asyncdispatch and another built on chronos (common when adding a "just works" HTTP client or web framework without checking its async foundation) produces the type-mismatch/hang failure modes described above, often deep inside generated or vendored code where the root cause is not obvious from the error message alone.

Recommended approach: Audit the async foundation of every networking/HTTP/timer dependency before adding it (grep -r "import chronos\|import asyncdispatch" across a candidate library's source, or check its .nimble requires line for a chronos dependency). Standardize on one framework at the project level and document the choice.

Pitfall: Assuming --threads:on still needs to be added manually

Problem: Older tutorials, blog posts, and copy-pasted nim.cfg/config.nims snippets emphasize adding --threads:on. On Nim >= 2.0 this is redundant (it is already the default) - worse, an LLM that treats this flag as the load-bearing fix for a threading problem will miss the actual, current-generation issue (usually a gcsafe violation or an unsafely shared ref) while confidently "fixing" a non-problem.

Recommended approach: Do not add --threads:on to new Nim >= 2.0 projects (harmless if present, but treat its absence from a diagnosis as a non-issue); if --threads:off is explicitly set in an inherited config file and thread code fails to compile, remove that override rather than assuming threading needs re-enabling elsewhere.

Pitfall: Passing a non-gcsafe-safe closure or shared ref across a raw thread boundary

Problem: Capturing a GC'd global (ref, seq, string, or a closure over one) in a proc passed to createThread without going through a channel, ptr, or Isolated[T] either fails to compile (gcsafe violation) or - if the check is bypassed with {.cast(gcsafe).} without actually ensuring exclusive ownership - compiles into a genuine data race on ARC/ORC's non-atomic refcounts, corrupting memory non-deterministically.

Recommended approach: Move data across the boundary via Isolated[T]/a Chan[T] channel, or share only ptr-typed data explicitly allocated outside the GC (e.g., alloc/createShared) and manage its lifetime manually. Treat {.cast(gcsafe).} exactly like unsafe blocks in other languages - an escape hatch that shifts the safety proof onto the author, not a fix.

Testing Strategies

  • Test the pure, single-threaded logic of a worker function directly, without spinning up threads, for correctness of the business logic itself.
  • For genuine concurrency-correctness tests (race conditions, deadlocks), prefer a small number of stress tests that run many iterations under ThreadSanitizer (--passC:-fsanitize=thread --passL:-fsanitize=thread, GCC/Clang backend) rather than relying on timing-based assertions alone - Nim's own issue tracker documents cases where withLock failed to prevent a race that only ThreadSanitizer caught.
  • For async code, test each ecosystem's constructs with its own idioms: waitFor an async proc under test for asyncdispatch; chronos ships its own unittest-integrated async test helpers - do not attempt to write a single test helper generic over both Future[T] types, for the same reason production code cannot mix them.
  • See Nim Testing Guide for std/unittest fundamentals that apply regardless of which concurrency model is under test.

Performance, Security & Scalability Considerations

  • Thread creation cost: OS threads are comparatively expensive to create relative to async tasks; prefer a long-lived pool (taskpools) over ad hoc createThread calls in hot paths.
  • Channel backpressure: both Channel[T] and Chan[T] support a bounded capacity; an unbounded channel accepting producer output faster than consumers drain it is a memory-exhaustion vector under load, exactly as with unbounded queues in any language.
  • Async event-loop starvation: a single blocking (synchronous, CPU-heavy) call inside an async proc - under either asyncdispatch or chronos - stalls the entire single-threaded event loop, exactly as in Python's asyncio; offload such work to a thread (taskpools/raw thread) rather than calling it inline.
  • Refcount races are memory-safety bugs, not just logic bugs: because ARC/ORC's refcounts are non-atomic, an unsynchronized shared-ref race under threading is not merely "wrong output" - it can corrupt the heap, unlike a typical unsynchronized-counter race in a tracing-GC language.

Edge Cases & Advanced Usage

  • Combining async and raw threads: a common, supported pattern is to run an asyncdispatch- or chronos-based event loop on the main thread while offloading CPU-bound chunks to a taskpools pool, then feeding results back via a channel or a Future completed from the worker thread - verify the chosen async framework's documented thread-safety guarantees for completing a Future from a non-owning thread before relying on this pattern, as this is one of the more fragile interop points even within a single framework.
  • --threads:off niches: a small binary that is provably single-threaded (a CLI tool, a WASM-style embedded script) may still deliberately compile with --threads:off for a smaller, simpler build; this remains a supported, if uncommon, choice.
  • Cross-compiling with threading: some minimal/embedded C target environments lack a pthreads-equivalent; confirm platform thread-library availability before assuming std/typedthreads works unmodified on an exotic target.

When Not to Use / Alternatives

DimensionRaw threads (std/typedthreads)taskpoolsweaveasyncdispatch/chronos
Best forLong-lived dedicated workersGeneral task-parallel CPU workFine-grained HPC/data-parallel workloadsI/O-bound concurrency (network, disk, timers)
CPU parallelismYes (manual)Yes (work-stealing)Yes (work-stealing, nestable)No (single-threaded loop)
Setup complexityHighest (manual join/lock management)LowModerate-to-high (HPC-oriented API)Low, but ecosystem-choice-sensitive
Ecosystem maturity (2026)Stable (stdlib)Actively maintained, growing adoptionActively maintained, HPC-nicheBoth actively maintained; not interoperable with each other

Choose plain synchronous, single-threaded code when the workload is neither I/O-bound at scale nor CPU-bound enough to justify threading's complexity - Nim's baseline performance without any concurrency is already close to C.

Glossary

  • ARC/ORC: Nim's default (since 2.0) deterministic, destructor-based memory management; ORC adds a cycle collector on top of ARC. Both use a shared heap across threads.
  • gcsafe: A pragma/effect asserting a proc does not unsafely touch globally-shared GC'd memory across threads.
  • Isolated[T]: A compiler-checked wrapper (std/isolation) proving a value has no remaining aliases, safe to move across a thread boundary.
  • Channel[T] / Chan[T]: The built-in system-based channel type and the newer threading/channels MPMC channel type, respectively.
  • Future[T]: The core async handle type; distinct and incompatible between std/asyncdispatch and chronos.
  • Work-stealing: A scheduling strategy (used by taskpools and weave) where idle worker threads pull tasks from busy peers' queues, improving load balance over a single global queue.

Maintenance note: When updating this document, re-verify the threading/channels API surface and taskpools/weave/malebolgia adoption levels against their current repositories, and re-check whether any asyncdispatch/chronos interop shim has been standardized since this was written.