Stateful Goroutines in Nim

import tables, random, atomics, os

type
  ReadOp = object
    key: int
    resp: Channel[int]

  WriteOp = object
    key: int
    val: int
    resp: Channel[bool]

proc main() =
  var 
    readOps: Atomic[uint64]
    writeOps: Atomic[uint64]
    reads = Channel[ReadOp]()
    writes = Channel[WriteOp]()

  # Goroutine that owns the state
  spawn do:
    var state = initTable[int, int]()
    while true:
      select:
      of reads.recv(read):
        read.resp.send(state.getOrDefault(read.key))
      of writes.recv(write):
        state[write.key] = write.val
        write.resp.send(true)

  # Start 100 read goroutines
  for r in 0..<100:
    spawn do:
      while true:
        let read = ReadOp(key: rand(5), resp: Channel[int]())
        reads.send(read)
        discard read.resp.recv()
        atomicInc(readOps)
        sleep(1)

  # Start 10 write goroutines
  for w in 0..<10:
    spawn do:
      while true:
        let write = WriteOp(key: rand(5), val: rand(100), resp: Channel[bool]())
        writes.send(write)
        discard write.resp.recv()
        atomicInc(writeOps)
        sleep(1)

  # Let the goroutines work for a second
  sleep(1000)

  # Capture and report the op counts
  let readOpsFinal = readOps.load()
  echo "readOps: ", readOpsFinal
  let writeOpsFinal = writeOps.load()
  echo "writeOps: ", writeOpsFinal

main()

This example demonstrates how to use channels and threads (Nim’s equivalent to goroutines) to manage state in a concurrent program. The approach aligns with the idea of sharing memory by communicating, where each piece of data is owned by exactly one thread.

In this example, our state is owned by a single thread. This guarantees that the data is never corrupted with concurrent access. To read or write that state, other threads send messages to the owning thread and receive corresponding replies. The ReadOp and WriteOp objects encapsulate these requests and provide a way for the owning thread to respond.

The main function starts by setting up channels for reads and writes, and atomic counters for operation counts. It then spawns a thread that owns the state (a Table in Nim, equivalent to a map in other languages). This thread repeatedly selects on the reads and writes channels, responding to requests as they arrive.

Next, it starts 100 threads to issue reads and 10 threads to issue writes. Each read or write operation constructs the appropriate operation object, sends it over the corresponding channel, and then receives the result.

After letting the threads work for a second, the program captures and reports the final operation counts.

To run this program:

$ nim c -r stateful_threads.nim
readOps: 71708
writeOps: 7177

For this particular case, the thread-based approach is more involved than a mutex-based one. However, it can be useful in certain scenarios, especially when dealing with multiple channels or when managing multiple mutexes would be error-prone. Choose the approach that feels most natural and helps you understand the correctness of your program more easily.