klain: namespace

klain:sync — Go-style goroutines & channels

The concurrency differentiator, and the one place the project aims for full Go fidelity: go(fn) to spawn a goroutine, a typed Channel<T> to communicate, select to wait on many at once — all running in parallel across every core, preemptively scheduled, in a single native binary. This is the deliberate answer to "why not just Workers or async?"

A concurrent program, end to end

Here is a real fan-out / fan-in pipeline: it counts the primes below half a million across a pool of goroutines, streams each hit back over a channel, and coordinates completion with select — the same program you'd write in Go.

import { go, Channel, select, defaultCase } from 'klain:sync'

const N = 500000
const WORKERS = 8

const primes = new Channel<number>(1024)  // every prime found, streamed to the collector
const done = new Channel<number>(0)        // a worker signals when its slice is exhausted

function isPrime(n: number): boolean {
  if (n < 2) return false
  if (n % 2 === 0) return n === 2
  for (let d = 3; d * d <= n; d += 2) {
    if (n % d === 0) return false
  }
  return true
}

// Fan out: worker `id` tests the interleaved slice id, id+WORKERS, id+2·WORKERS…
function spawnWorker(id: number): void {
  go(() => {
    for (let n = 2 + id; n <= N; n += WORKERS) {
      if (isPrime(n)) primes.send(n)
    }
    done.send(id)
  })
}
for (let w = 0; w < WORKERS; w++) spawnWorker(w)

// Fan in: count primes as they stream in, tally completions with select, then
// drain any still-buffered primes with a non-blocking default.
let count = 0
let finished = 0
while (finished < WORKERS) {
  select(
    primes.recvCase((p: number) => { count += 1 }),
    done.recvCase((id: number) => { finished += 1 }),
  )
}
let draining = true
while (draining) {
  select(
    primes.recvCase((p: number) => { count += 1 }),
    defaultCase(() => { draining = false }),
  )
}
console.log(`primes below ${N}: ${count}`)
output
$ klainmain build parallel_primes.ts && ./parallel_primes
primes below 500000: 41538
(found by 8 goroutines running in parallel)

Open in the example gallery →

Goroutines & the scheduler

go(() => { … }) spawns a goroutine — far cheaper than an OS thread — onto a real GMP work-stealing scheduler built into an embedded C runtime that is linked only when you import klain:sync (a program that never imports it pays and links nothing).

  • G — a goroutine: its own stack and saved execution context.
  • M — an OS thread (pthread).
  • P — a logical processor with a local run queue; GOMAXPROCS defaults to the CPU count, so N runnable goroutines run on N cores — real parallelism, unlike async (single-threaded by contract).

Idle processors steal half of a busy one's queue, and a global run queue backstops them, so work spreads evenly with no central bottleneck.

Channels

A Channel<T> is a Go channel: a synchronous, blockingsend/receive that parks the goroutine — the OS thread keeps running other goroutines — rather than blocking a whole thread. Capacity 0 is an unbuffered rendezvous; a positive capacity is a buffered ring.

const ch = new Channel<number>(0)  // unbuffered rendezvous

go(() => { ch.send(42) })          // parks the goroutine until a receiver arrives
console.log(ch.receive())          // 42

// Range a channel until it is closed and drained:
const nums = new Channel<number>(0)
go(() => {
  for (let i = 1; i <= 5; i++) nums.send(i * i)
  nums.close()
})
let sum = 0
for (const v of nums) sum += v     // 1 + 4 + 9 + 16 + 25
console.log(sum)                   // 55

Ranging a channel with for (const v of ch) receives until the channel is close()d and drained — the idiomatic way to consume a stream of values.

select

select waits on several channel operations at once and proceeds with whichever is ready; if several are ready it picks pseudo-randomly (Go's fairness), and if none are ready it blocks the goroutine until one fires — unless a defaultCase makes it non-blocking.

const a = new Channel<number>(0)
const b = new Channel<number>(0)

go(() => { a.send(1) })
go(() => { b.send(2) })

// Take whichever is ready first; if neither is, block until one fires.
select(
  a.recvCase((v: number) => { console.log('from a:', v) }),
  b.recvCase((v: number) => { console.log('from b:', v) }),
)

// With a default, select never blocks:
select(
  a.recvCase((v: number) => { console.log('got', v) }),
  defaultCase(() => { console.log('nothing ready') }),
)

Preemptive by default

A busy goroutine can't starve the others. A sysmon helper thread flags any goroutine that has run too long, and the compiler inserts a tiny preempt check at every function entry and every loop back-edge — so even a tight compute loop with no channel operations yields. Because every loop is instrumented, cooperative preemption is complete here; there is no uninstrumented loop that Go's signal-based async preemption would need to catch.

When a goroutine makes a genuinely blocking call, work-stealing lets the other processors keep draining its queue, and if every thread blocks at once a bounded rescue thread is spawned so the program can't deadlock.

Tuning

  • GOMAXPROCS — number of logical processors (parallelism); defaults to the CPU count.
  • KLAINSYNC_STACK_KB — per-goroutine stack size in KiB (default 256); lower it to pack far more goroutines, raise it for deep recursion.

Scope fence. None of this touches async/await, Promises, or Workers — those stay JS-faithful and single-threaded so ordinary code keeps running under Node. Goroutines are a separate, opt-in world reached only through klain:sync, and channel operations are synchronous (Go has no await on a channel). Only the moving/growable-stack optimisation is still ahead; it shares the precise-stack-map work with the moving-GC backend.