DEV Community

puffball1567
puffball1567

Posted on

Nim Concurrency Explained: Async/Await, Threads, Channels, and Parallel Work

Nim gives a program more than one way to work on several tasks at once. That is useful, but it also means that “parallel execution” is not one feature with one answer. A service waiting on many network requests needs a different model from an image-processing job that needs to use several CPU cores.

This article separates those cases. We will use Nim's standard asynchronous runtime for I/O-bound work, then look at native threads and channels for CPU-bound work. The goal is not to introduce concurrency everywhere; it is to choose a model that matches the work.

For a Go-oriented view of similar ideas, see Go Concurrency: How to Run Parallel Tasks with Goroutines, Channels, and Context.

Nim concurrency vs parallelism

Concurrency means a program can keep multiple tasks in progress. Parallelism means multiple tasks execute at the same moment on separate CPU cores. They often appear together, but they solve different bottlenecks.

If ten HTTP requests are waiting on remote servers, the bottleneck is mostly I/O. An event loop can let another request make progress while one is waiting, without giving each request a dedicated OS thread. If ten large images need to be resized, the bottleneck is CPU work. To use several cores, the program needs real parallel execution through threads or a task-pool library.

Workload Start with Why
HTTP, sockets, timers, file readiness asyncdispatch and async / await One event loop can manage many waiting operations efficiently.
CPU-heavy parsing, hashing, encoding, image work threads or a maintained task-pool package CPU work must run on separate threads to use more cores.
A long-running dedicated background task Thread and createThread The lifecycle and ownership are explicit.
Durable jobs that must survive restarts A database or durable queue Threads and channels alone do not persist unfinished work.

Nim async/await for I/O-bound concurrency

Nim's std/asyncdispatch provides an event loop and Future[T] values. Mark a procedure with {.async.} when it will suspend at await. Calling that procedure returns a future: a handle for a result that will become available later.

The following program starts two simulated network operations before waiting for either result. sleepAsync stands in for an operation such as an asynchronous HTTP request; it does not block the event loop while waiting.

import std/[asyncdispatch, strformat]

proc fetchPreview(url: string): Future[string] {.async.} =
  echo "starting ", url
  await sleepAsync(200) # Simulates waiting for network I/O without blocking the loop.
  return &"finished {url}"

proc main() {.async.} =
  # Both procedures reach their first await before main waits for a result.
  let first = fetchPreview("https://api.example.com/one")
  let second = fetchPreview("https://api.example.com/two")

  let previews = await all(first, second)
  for preview in previews:
    echo preview

waitFor main()
Enter fullscreen mode Exit fullscreen mode

waitFor main() drives the event loop from a normal command-line program. In a server or GUI application, the application's existing event-loop integration determines where futures are awaited. The key point is that await yields control while an asynchronous operation is waiting, allowing other ready work on the same event loop to proceed.

This is concurrent I/O, not a promise that two CPU-intensive loops will run in parallel. Do not put a long CPU loop inside an async procedure and expect await elsewhere to make it responsive. The event loop only gets another opportunity to run when the current task reaches an awaitable operation.

A real Nim async HTTP example

Nim's standard library includes AsyncHttpClient for asynchronous HTTP work. The following example starts two GET requests, then waits for both response bodies.

import std/[asyncdispatch, httpclient]

proc fetchBody(client: AsyncHttpClient; url: string): Future[string] {.async.} =
  let response = await client.get(url)
  return await response.body

proc main() {.async.} =
  let client = newAsyncHttpClient()

  let first = fetchBody(client, "https://example.com")
  let second = fetchBody(client, "https://example.org")

  let bodies = await all(first, second)
  for body in bodies:
    echo body.len

waitFor main()
Enter fullscreen mode Exit fullscreen mode

Treat a real client as a resource with a lifecycle. Set timeouts, limit how many operations you start at once, handle HTTP status codes and failures, and close or reuse clients according to the API you choose. Starting thousands of futures at once can still overload a remote service or consume too much memory. Async I/O removes the need for one thread per wait; it does not remove rate limits or capacity planning.

Native threads for CPU-bound parallel work in Nim

Nim's native threads are appropriate when work should occupy multiple CPU cores. A thread entry procedure is marked with {.thread.}, a Thread[T] value represents the running thread, and createThread starts it. joinThread waits for it to finish.

import std/[math, typedthreads]

type Work = tuple[startAt: int, endAt: int]

proc sumSquares(work: Work) {.thread.} =
  var total = 0.0
  for number in work.startAt..work.endAt:
    total += sqrt(number.float)
  echo total

var worker: Thread[Work]
createThread(worker, sumSquares, (1, 1_000_000))

# The main thread can perform other work here.
worker.joinThread()
Enter fullscreen mode Exit fullscreen mode

Compile thread-based programs with thread support enabled:

nim c -r --threads:on app.nim
Enter fullscreen mode Exit fullscreen mode

Current Nim documentation notes that --threads:on is enabled by default, but keeping the switch in an example makes the threading requirement visible and works with toolchains where it is not the default. A thread is much heavier than an async future. Do not create one native thread per input record; bound the number of active workers to the work and available CPU resources.

Pass thread results through a Nim channel

Nim's Channel[T] is designed for communication between Thread values. A sending thread places a value into the channel with send; the receiving thread obtains it with recv. The example below gives each worker an immutable range and returns its partial result through a module-level channel.

import std/[typedthreads, math]

type Work = tuple[startAt: int, endAt: int]
type PartialResult = tuple[startAt: int, total: float]

var results: Channel[PartialResult]

proc sumSquares(work: Work) {.thread.} =
  var total = 0.0
  for number in work.startAt..work.endAt:
    total += sqrt(number.float)

  results.send((work.startAt, total))

results.open()

var workers: array[2, Thread[Work]]
createThread(workers[0], sumSquares, (1, 500_000))
createThread(workers[1], sumSquares, (500_001, 1_000_000))

var combined = 0.0
for _ in 0..<workers.len:
  let partial = results.recv() # Wait for one worker result.
  combined += partial.total

for worker in workers.mitems:
  worker.joinThread()

results.close()
echo combined
Enter fullscreen mode Exit fullscreen mode

The channel here is module-level on purpose. Nim's documentation explains that channels are safest when stored in process-wide shared memory; passing an ordinary heap-allocated channel to a thread through a pointer requires careful shared allocation. It also notes that values sent through channels are deeply copied and that cyclic data structures are not supported by the current message-passing implementation.

That means a good first thread design is usually: give a worker compact input data, let it create thread-local temporary state, and return a compact result. Avoid casually sharing a mutable seq, Table, ref object, or client instance between threads.

Locks and shared state: prefer ownership first

Threads create the possibility of simultaneous access to shared state. If two threads update the same counter, map, or object without synchronization, the result is a data race.

When a shared value is unavoidable, use the synchronization primitive that represents the rule. A Lock can protect a short, shared update.

import std/locks

var totalLock: Lock
var total: int

proc addToTotal(value: int) {.thread.} =
  acquire(totalLock)
  total += value
  release(totalLock)

initLock(totalLock)
# Create and join threads that call addToTotal here.
deinitLock(totalLock)
Enter fullscreen mode Exit fullscreen mode

Keep the lock scope small. Do not acquire a lock and then make a network request, perform slow disk I/O, or wait for another unbounded operation. More importantly, consider whether the state needs to be shared at all. Passing results through a channel and combining them in one owner thread often produces a simpler design.

Nim additionally offers guards and lock annotations for selected shared locations. They can make intended locking rules visible and help the compiler check accesses. They are useful once a design genuinely needs shared state, but they are not a substitute for deciding ownership and lifecycle first.

Why the built-in Nim threadpool is not the default recommendation

You may find older Nim examples using std/threadpool, spawn, FlowVar, and parallel. They are part of Nim's history and still appear in documentation, but the current std/threadpool documentation marks the module deprecated and its API unstable. It recommends maintained Nimble packages such as malebolgia, taskpools, or weave instead.

That does not mean old code is automatically wrong. It means a new application should choose deliberately: use asyncdispatch for I/O concurrency, use Thread and Channel when a small number of dedicated native threads is enough, or evaluate a maintained task-pool package for a larger CPU work queue. Do not choose a thread pool only because an operation is asynchronous; first identify whether the bottleneck is waiting or computation.

Cancellation, retries, and durable work are separate concerns

An asynchronous operation or thread does not become a reliable background job merely because it runs outside the caller's immediate control. If a process exits, memory-only jobs and channels disappear. If an HTTP request is cancelled, it can be correct to stop optional work such as a page preview or a search suggestion.

For a payment, a required notification, or another state change that must eventually happen, persist the job state in a database or durable queue. Make retries idempotent, record whether an attempt may have completed, and make recovery possible after a restart. This is the same distinction that matters in Go worker pools: cancellation is for work that is no longer needed; durable delivery is for work that must not be lost.

A practical Nim concurrency checklist

  • Use async / await and asyncdispatch for I/O-bound waiting.
  • Use native threads only when CPU-bound work benefits from several cores or a task must have its own dedicated thread.
  • Do not block an event loop with long CPU work.
  • Bound concurrency; async futures and threads can both overwhelm a downstream service when created without limits.
  • Keep thread inputs and results small, explicit, and independent where possible.
  • Use Channel[T] for thread communication, and be careful about its copying and shared-memory rules.
  • Use locks only around short shared-state operations; prefer a single owner when that models the problem cleanly.
  • Do not use memory-only concurrency primitives as a durable queue.

Nim's official asyncdispatch documentation, thread manual section, channel documentation, and threadpool documentation are good references when choosing an implementation.

Related projects

  • KoutenDB — An open-source document and vector database written in Nim. Unlike a conventional relational database, it uses a locality-first retrieval model: related records are placed in application-defined ring coordinates, and that placement determines the initial retrieval scope, persistent disk locality, and cluster routing boundary. KoutenDB uses this model to begin from relevant local data instead of broadly reading unrelated data and filtering it afterward, aiming to reduce retrieval work and the candidate context sent to RAG or LLM systems.

  • Clay Board Style System — A CSS-inspired primitive engine for building expressive native GUI toolkits without a DOM or WebView. It lets applications keep component behavior in typed host-language code while sharing portable presentation across Nim, C++, and Rust.

  • Joubako — An application-facing asynchronous HTTP client for Nim. It uses standard Nim async foundations while adding typed results, retries, streaming, TLS, and production-oriented request controls.

Related reading

Go Concurrency: How to Run Parallel Tasks with Goroutines, Channels, and Context explains the same broad concerns from a Go perspective, including goroutines, channels, worker pools, cancellation, and retries.

Top comments (0)