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:onis 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:oncompiler 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:onas "the default." You may still see--threads:onwritten explicitly in older tutorials, CI scripts, and Nimble tasks - it is harmless but no longer necessary on Nim >= 2.0.--threads:offstill exists and can be passed explicitly to disable OS-thread support entirely (smaller binaries, noThread[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
refcGC: Under the legacy--mm:refcgarbage collector (Nim 1.x's default), each thread owned its own independent heap, and passing arefacross 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 - aref,seq, orstringcan 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 samerefwill 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 anIsolated[T]only when the compiler can prove no other alias tovalue'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 arefto another thread" provably safe rather than merely conventionally safe.{.gcsafe.}asserts a proc touches no globally-shared GC'd memory unsafely: Agcsafeproc does not read or write any global variable holding GC'd memory (ref,string,seq, closures) - directly, or indirectly via calling a non-gcsafeproc - except through the isolation/move discipline above. This is what prevents two threads from racing on a shared global's refcount.createThreadrequires 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/awaitin bothstd/asyncdispatchandchronosruns 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 withspawn/taskpools/weavefor 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.
- The built-in
Channel[T], part of thesystemmodule's threading support (compiled in whenever--threads:onis active; historically documented assystem/channels_builtin). It providesopen/close, blockingsend/recv, and non-blockingtrySend/tryRecv, bounded by an optionalmaxItems(0 = unbounded). Its own documentation notes it is unstable when combined withspawn/std/threadpool, and aChannelinstance itself cannot be copied or passed by value between threads - you must share it via aptror aglobal.
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()
threading/channels(Chan[T]), from the separate, actively developednim-lang/threadingNimble package. This is the modern, ARC/ORC-native multi-producer/multi-consumer channel, designed from the outset to moveIsolated[T]data safely, rather than relying on the oldersystemchannel's implicit conventions. It requires addingthreadingas 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.
| Library | Status | Model | Use 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-stealing | Never for new code - kept only for legacy compatibility |
taskpools (status-im/nim-taskpools) | Actively maintained, lightweight, ARC/ORC-native | Work-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-oriented | Nestable 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 counts | Numerically intensive, deeply nested parallel workloads where taskpools' simpler model is insufficient |
malebolgia | Newer, lighter-weight structured-concurrency-style library, mentioned directly in threadpool's own deprecation message as an alternative | Structured spawn/await over a thread pool | Verify 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 ofasyncdispatch.Future[T]. - A unified callback signature (
CallbackFunc = proc (arg: pointer = nil)), versusasyncdispatch's several distinct callback shapes (proc()forcallSoon,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 itsasyncmacro, whichasyncdispatchdoes 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-runningnim-lang/RFCsdiscussion on "Multiple async implementations"); by its v4 release it had diverged far enough (ownFuturetype, 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 toawaita chronos-returnedFuture[string]inside anasyncdispatch-flavoredasync proc, or passing one where the other is expected, produces an ordinary "type mismatch" error citing two apparently identical-looking but module-distinctFuture[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, aFuturecreated by chronos's I/O primitives will never be driven to completion byasyncdispatch'swaitFor/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:
- Decide up front:
std/asyncdispatch(zero extra dependency, adequate for most application-level I/O) orchronos(required if you depend onlibp2p,presto, or anything in the Status/Nimbus ecosystem, or if you need its stronger effect-tracking and I/O feature set). - 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 tonimble installtwo libraries that silently commit you to both ecosystems. - Never
import asyncdispatchandimport 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 wherewithLockfailed to prevent a race that onlyThreadSanitizercaught. - For async code, test each ecosystem's constructs with its own idioms:
waitForanasync procunder test forasyncdispatch; chronos ships its ownunittest-integrated async test helpers - do not attempt to write a single test helper generic over bothFuture[T]types, for the same reason production code cannot mix them. - See Nim Testing Guide for
std/unittestfundamentals 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 hoccreateThreadcalls in hot paths. - Channel backpressure: both
Channel[T]andChan[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 eitherasyncdispatchorchronos- stalls the entire single-threaded event loop, exactly as in Python'sasyncio; 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-
refrace 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- orchronos-based event loop on the main thread while offloading CPU-bound chunks to ataskpoolspool, then feeding results back via a channel or aFuturecompleted from the worker thread - verify the chosen async framework's documented thread-safety guarantees for completing aFuturefrom 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:offniches: a small binary that is provably single-threaded (a CLI tool, a WASM-style embedded script) may still deliberately compile with--threads:offfor 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/typedthreadsworks unmodified on an exotic target.
When Not to Use / Alternatives
| Dimension | Raw threads (std/typedthreads) | taskpools | weave | asyncdispatch/chronos |
|---|---|---|---|---|
| Best for | Long-lived dedicated workers | General task-parallel CPU work | Fine-grained HPC/data-parallel workloads | I/O-bound concurrency (network, disk, timers) |
| CPU parallelism | Yes (manual) | Yes (work-stealing) | Yes (work-stealing, nestable) | No (single-threaded loop) |
| Setup complexity | Highest (manual join/lock management) | Low | Moderate-to-high (HPC-oriented API) | Low, but ecosystem-choice-sensitive |
| Ecosystem maturity (2026) | Stable (stdlib) | Actively maintained, growing adoption | Actively maintained, HPC-niche | Both 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.
Related Documentation
- Nim Language Overview & Ecosystem
- Nim Best Practices
- Nim Common Pitfalls & Anti-Patterns
- Nim Metaprogramming
- Nim Testing Guide
- Nim FFI & Interop
- Rust Best Practices - Tokio's single-runtime model as a useful contrast to Nim's split async ecosystem
- SOLID Principles
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-insystem-based channel type and the newerthreading/channelsMPMC channel type, respectively.Future[T]: The core async handle type; distinct and incompatible betweenstd/asyncdispatchandchronos.- Work-stealing: A scheduling strategy (used by
taskpoolsandweave) 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.