Concurrency
Chuks has first-class concurrency built into the language. There are two complementary primitives:
| Primitive | Purpose | Runs concurrently? |
|---|---|---|
async / await | Non-blocking I/O-bound work | Yes (goroutine) |
spawn | CPU-bound parallel work | Yes (goroutine) |
Both async and spawn create real OS-level goroutines under the hood. The difference is when they’re used and what they express:
async/await— Mark a function as asynchronous. Calling it always runs it in a concurrent goroutine and returns aTask<T>. Useawaitto get the result. Designed for I/O-bound operations like HTTP requests, database queries, and file reads.spawn— Run any function (async or not) in a parallel goroutine. Returns aTask<T>. Designed for CPU-bound operations like number crunching, data processing, and parallel algorithms.
Async/Await
Section titled “Async/Await”Declaring Async Functions
Section titled “Declaring Async Functions”An async function always runs in a separate goroutine when called and returns a Task<T>:
async function fetchUser(id: int): Task<string> { // This runs concurrently in its own goroutine return "User " + string(id)}Awaiting Results
Section titled “Awaiting Results”Use await to pause the current execution until the async function completes:
// Calling an async function returns a Task<T>// await blocks until the result is readyvar user: string = await fetchUser(42)println(user) // "User 42"Sequential Async
Section titled “Sequential Async”When you await each call before starting the next, execution is sequential:
async function fetchName(): Task<string> { return "Alice"}
async function fetchAge(): Task<int> { return 30}
// Sequential — fetchAge waits for fetchName to finishvar name: string = await fetchName()var age: int = await fetchAge()println(name + " is " + string(age))Concurrent Async
Section titled “Concurrent Async”Start all tasks first, then await them to run concurrently:
import { http } from "std/http"
async function fetchData(url: string): Task<string> { var resp = await http.get(url) return resp.body}
// Start both requests concurrentlyvar t1: Task<string> = fetchData("https://api.example.com/users")var t2: Task<string> = fetchData("https://api.example.com/posts")
// Both are running — now await the resultsvar users: string = await t1var posts: string = await t2Chaining Async Calls
Section titled “Chaining Async Calls”Async functions can call other async functions:
async function getUser(id: int): Task<string> { return "User-" + string(id)}
async function getFullProfile(id: int): Task<string> { var name: string = await getUser(id) return name + " (full profile)"}
var profile: string = await getFullProfile(1)println(profile) // "User-1 (full profile)"Error Propagation
Section titled “Error Propagation”Errors in async functions propagate to the caller through try/catch:
async function riskyOperation(): Task<string> { throw "something went wrong" return "ok"}
try { var result: string = await riskyOperation()} catch (e) { println("Caught: " + string(e))}Running Functions in Parallel
Section titled “Running Functions in Parallel”spawn takes any function — async or regular — and runs it in a parallel goroutine:
function computeSum(n: int): int { var sum: int = 0 for (var i: int = 0; i < n; i = i + 1) { sum = sum + i } return sum}
// Run in parallel — even though computeSum is NOT asyncvar task: Task<int> = spawn computeSum(1000000)var result: int = await taskprintln(result)Parallel Fan-Out
Section titled “Parallel Fan-Out”Spawn multiple workers and collect results:
function countPrimes(start: int, end: int): int { var count: int = 0 var i: int = start while (i < end) { if (isPrime(i)) { count = count + 1 } i = i + 1 } return count}
// Fan-out: 4 parallel workersvar t1: Task<int> = spawn countPrimes(0, 250000)var t2: Task<int> = spawn countPrimes(250000, 500000)var t3: Task<int> = spawn countPrimes(500000, 750000)var t4: Task<int> = spawn countPrimes(750000, 1000000)
// Collect resultsvar total: int = await t1 + await t2 + await t3 + await t4println("Total primes: " + string(total))Fire-and-Forget
Section titled “Fire-and-Forget”If you don’t need the result, just spawn without awaiting:
function logEvent(msg: string): void { println("LOG: " + msg)}
spawn logEvent("user signed in")println("continues immediately")Async vs Spawn — When to Use Which
Section titled “Async vs Spawn — When to Use Which”| Scenario | Use | Why |
|---|---|---|
| HTTP request, DB query, file read | async/await | The function is I/O-bound and naturally asynchronous |
| Prime counting, data crunching | spawn | The function is CPU-bound and needs a parallel thread |
| Regular function, run in parallel | spawn | Only spawn can make a non-async function concurrent |
| Async function, run in parallel | either | Both work — spawn on an async function is redundant |
Key insight: For async functions, await asyncFunc() and await spawn asyncFunc() are functionally identical — both create a goroutine. The unique value of spawn is that it works on regular (non-async) functions too, enabling parallel computation without requiring the function to be marked async.
Design Philosophy
Section titled “Design Philosophy”async/await → I/O-bound (waiting for external resources)spawn → CPU-bound (parallel computation)Both are concurrent. The distinction is about intent: async signals “this function does I/O and should be non-blocking”, while spawn signals “run this computation in parallel for performance”.
Async Class Methods
Section titled “Async Class Methods”Class methods can be marked async just like top-level functions. This is useful for services, repositories, and any class that performs I/O or background work.
class Calculator { public async add(a: int, b: int): Task<int> { return a + b }
public async multiply(a: int, b: int): Task<int> { return a * b }}Call async methods with await directly, or spawn them for parallel execution:
var calc = new Calculator()
// Sequentialvar sum: int = await calc.add(3, 4)println(sum) // 7
// Parallelvar t1: Task<int> = spawn calc.add(10, 20)var t2: Task<int> = spawn calc.multiply(5, 6)var r1: int = await t1var r2: int = await t2println(r1) // 30println(r2) // 30Async methods work with all access modifiers (public, protected, private), static, and override:
abstract class DataService { abstract public async fetch(id: int): Task<any>}
class UserService extends DataService { override public async fetch(id: int): Task<any> { // fetch user from database... return { "id": id, "name": "Alice" } }}Channels
Section titled “Channels”When spawning background tasks, you often need them to communicate with the main thread or with each other. Chuks provides channels — typed, synchronized pipes for sending values between concurrent tasks.
Think of a channel as a mailbox: one task drops a message in, another task picks it up. The channel guarantees that messages are delivered safely, without race conditions.
Channel<T> is a generic class — the element type T is required. new Channel<any>(...) is rejected by the type checker; pick a concrete type so the compiler can prevent you from sending the wrong shape.
Creating a Channel
Section titled “Creating a Channel”import { Channel } from "std/channel"
// A buffered channel that can hold up to 5 intsconst ch = new Channel<int>(5);The constructor argument is the buffer size — how many messages the channel can hold before a sender must wait for a receiver. A buffer of 0 (the default) means every send blocks until another task calls receive, and vice versa.
| Buffer Size | Behavior |
|---|---|
0 | Unbuffered — sender blocks until receiver is ready, and receiver blocks until sender sends. This gives tight synchronization. |
> 0 | Buffered — sender can push up to N messages without blocking. Once the buffer is full, the next send blocks until a receive frees a slot. |
Sending and Receiving
Section titled “Sending and Receiving”import { Channel } from "std/channel"
const ch = new Channel<string>(1);
// Send a value into the channelch.send("hello from channel");
// Receive the value on the other end. receive() returns T?:// the value, or null if the channel is closed and empty.const msg = ch.receive();println(msg); // "hello from channel"
// Always close channels when donech.close();ch.send(value) blocks if the buffer is full.
ch.receive() blocks if the buffer is empty, and returns null once the channel is closed and drained.
Non-Blocking Operations
Section titled “Non-Blocking Operations”Sometimes you don’t want to wait. tryReceive() and trySend() return immediately regardless of whether the operation succeeded.
import { Channel } from "std/channel"
const ch = new Channel<int>(1);
// tryReceive on an empty channel — does not blockconst result = ch.tryReceive();println(result.ok); // false — nothing was available
// trySend on a non-full channel — succeeds immediatelyconst sent = ch.trySend(42);println(sent); // true
// trySend on a full channel (buffer=1, already holding 42)const sent2 = ch.trySend(99);println(sent2); // false — buffer is full, would block
// tryReceive now gets the valueconst result2 = ch.tryReceive();println(result2.value); // 42println(result2.ok); // true
ch.close();| Method | Returns | When to use |
|---|---|---|
ch.tryReceive() | { value: T?, ok: bool } | Check for a message without blocking (e.g., polling in a loop) |
ch.trySend(value) | bool | Send if possible, skip if the buffer is full (e.g., dropping non-critical events) |
Use Case: Producer-Consumer
Section titled “Use Case: Producer-Consumer”The most common channel pattern. One task produces data, another consumes it. The channel acts as a queue between them.
import { Channel } from "std/channel"
const dataCh = new Channel<int>(5);
function produce(ch: Channel<int>, count: int): void { for (var i: int = 0; i < count; i++) { ch.send(i * 10); }}
// Producer fills the channelproduce(dataCh, 5);
// Consumer reads all valuesvar total: int = 0;for (var i: int = 0; i < 5; i++) { const val = dataCh.receive(); // int? if (val != null) total = total + val;}println("total: " + string(total)); // "total: 100"dataCh.close();With spawn, the producer and consumer can run in parallel:
import { Channel } from "std/channel"
const ch = new Channel<int>(10);
function producer(ch: Channel<int>): void { for (var i: int = 0; i < 10; i++) { ch.send(i); } ch.close();}
// Run producer in backgroundspawn producer(ch);
// Drain values until the channel closesfor (var v = ch.receive(); v != null; v = ch.receive()) { println("received: " + string(v));}Use Case: Spawn + Channel (Background Work)
Section titled “Use Case: Spawn + Channel (Background Work)”When you need the result of a background computation but want it delivered through a channel instead of a Task:
import { Channel } from "std/channel"
const resultCh = new Channel<int>(1);
function heavyComputation(n: int): int { var sum: int = 0; for (var i: int = 0; i < n; i++) { sum = sum + i; } return sum;}
function computeAndSend(ch: Channel<int>, n: int): void { const result = heavyComputation(n); ch.send(result);}
// Run in background, get result through channelspawn computeAndSend(resultCh, 100);const result = resultCh.receive();println("result: " + string(result)); // "result: 4950"resultCh.close();Use Case: Synchronization Signal
Section titled “Use Case: Synchronization Signal”Use a channel as a simple “done” signal — the value doesn’t matter, just the act of sending it.
import { Channel } from "std/channel"
const doneCh = new Channel<bool>(1);
function backgroundWork(done: Channel<bool>): void { println("background: started"); // ... do work ... println("background: finished"); done.send(true);}
spawn backgroundWork(doneCh);
// Block until background work signals completionconst signal = doneCh.receive();println("main: background done=" + string(signal));doneCh.close();Output:
background: startedbackground: finishedmain: background done=trueUse Case: Buffered Queue
Section titled “Use Case: Buffered Queue”Buffered channels act as bounded, thread-safe queues. Send multiple values, read them back in FIFO order:
import { Channel } from "std/channel"
const queue = new Channel<string>(3);
queue.send("first");queue.send("second");queue.send("third");
println(queue.receive()); // "first"println(queue.receive()); // "second"println(queue.receive()); // "third"
queue.close();Channel API Reference
Section titled “Channel API Reference”| Method | Description |
|---|---|
new Channel<T>(size?) | Create a channel. size sets the buffer (default 0). |
ch.send(value) | Send a value. Blocks if the buffer is full. |
ch.receive() | Receive a value. Returns T? — null once closed and drained. |
ch.close() | Close the channel. No more sends allowed. |
ch.tryReceive() | Non-blocking receive. Returns { value: T?, ok: bool }. |
ch.trySend(value) | Non-blocking send. Returns true if sent, false otherwise. |
For an end-to-end fan-out/fan-in example, see the Worker Pool with Channels tutorial.
When to Use Channels vs Await
Section titled “When to Use Channels vs Await”| Scenario | Use | Why |
|---|---|---|
| Get the return value of a background function | await spawn fn() | Simpler — just await the Task |
| Stream multiple values from a background task | Channel | Tasks return one value; channels carry many |
| Coordinate multiple tasks (producer/consumer) | Channel | Channels decouple producers from consumers |
| Signal completion (“done”) | Channel | A lightweight alternative to awaiting a Task |
| Polling without blocking | ch.tryReceive() | Non-blocking check for available data |
Task API
Section titled “Task API”When you call an async function or use spawn, it returns a Task<T> object. The Task API lets you inspect and control tasks.
Task Properties
Section titled “Task Properties”| Property | Type | Description |
|---|---|---|
state | string | Current state: "pending", "done", "cancelled", or "failed" |
completed | bool | Whether the task has finished (successfully or not) |
value | T | The resolved value (only available after completion) |
context | Context | The task’s execution context |
Task Methods
Section titled “Task Methods”| Method | Return Type | Description |
|---|---|---|
cancel() | void | Cancel the task and all its child tasks |
timeout(ms) | void | Set a timeout in milliseconds |
isCancelled() | bool | Check if the task has been cancelled |
isCompleted() | bool | Check if the task has completed |
| Static Method | Return Type | Description |
|---|---|---|
Task.all(tasks) | []any | Await all tasks in parallel, return results as an array |
Task.current() | Task? | Return the currently executing task (null if none) |
Task.all()
Section titled “Task.all()”Task.all(tasks) takes an array of tasks, awaits all of them in parallel, and returns an array of their results in the same order. If any task throws an error, Task.all propagates it.
async function doubleAsync(n: int): Task<int> { return n * 2}
async function main(): Task<int> { var t1: Task<int> = spawn doubleAsync(5) var t2: Task<int> = spawn doubleAsync(10) var t3: Task<int> = spawn doubleAsync(15)
var results: []any = Task.all([t1, t2, t3]) println(results[0]) // 10 println(results[1]) // 20 println(results[2]) // 30 return 0}
var m: Task<int> = spawn main()await mBuilding tasks in a loop:
async function doubleAsync(n: int): Task<int> { return n * 2}
async function main(): Task<int> { var tasks: []Task<int> = [] for (var i: int = 0; i < 4; i = i + 1) { tasks.push(spawn doubleAsync(i)) }
var results: []any = Task.all(tasks) for (var j: int = 0; j < 4; j = j + 1) { println(results[j]) // 0, 2, 4, 6 } return 0}
var m: Task<int> = spawn main()await mError propagation — if any task throws, Task.all re-throws inside a try/catch:
async function failingTask(): Task<int> { throw "something went wrong" return 0}
async function main(): Task<int> { var t1: Task<int> = spawn doubleAsync(5) var t2: Task<int> = spawn failingTask() try { var results: []any = Task.all([t1, t2]) } catch (e) { println("caught error") // "caught error" } return 0}
var m: Task<int> = spawn main()await mTask.current()
Section titled “Task.current()”The static method Task.current() returns the currently executing task from within a spawned function. Returns null when called outside a spawned context.
async function worker(): Task<string> { var t: any = Task.current() if (t != null) { return "running inside a task" } return "no task context"}
var result: string = await spawn worker()println(result) // "running inside a task"Cancellation
Section titled “Cancellation”async function longWork(): Task<int> { var sum: int = 0 for (var i: int = 0; i < 1000000; i = i + 1) { sum = sum + i } return sum}
var task: Task<int> = spawn longWork()task.cancel()println(task.state) // "cancelled"Timeout
Section titled “Timeout”async function riskyOp(): Task<string> { // Long running operation... return "done"}
var task: Task<string> = spawn riskyOp()task.timeout(5000) // Cancel automatically after 5 secondsvar result: string = await taskContext & Structured Concurrency
Section titled “Context & Structured Concurrency”Every spawned task runs within a Context. Contexts form a tree: when a parent task is cancelled, all child tasks are automatically cancelled too. This is the foundation of structured concurrency in Chuks.
Context Hierarchy
Section titled “Context Hierarchy”Root Context├── Task A (Context A)│ ├── Task A1 (Context A1)│ └── Task A2 (Context A2)└── Task B (Context B)Cancelling Task A automatically cancels Task A1 and Task A2, but Task B is unaffected.
Context Methods
Section titled “Context Methods”| Method | Return Type | Description |
|---|---|---|
withValue(k, v) | void | Store a key-value pair in the context |
value(key) | any | Retrieve a value by key (walks up the parent chain) |
cancel() | void | Cancel this context and all children |
isCancelled() | bool | Check if the context has been cancelled |
deadline() | string | Get the deadline as a string (empty if none set) |
setTimeout(ms) | void | Auto-cancel the context after ms milliseconds |
Accessing the Context
Section titled “Accessing the Context”Use Task.current() to get the current task, then access its .context property:
async function handler(): Task<string> { var t: any = Task.current() if (t != null) { var ctx: any = t.context ctx.withValue("requestId", "abc-123")
// Child tasks inherit parent context values var child: Task<string> = spawn childHandler() return await child } return "no context"}
async function childHandler(): Task<string> { var t: any = Task.current() if (t != null) { var ctx: any = t.context var reqId: any = ctx.value("requestId") // reqId is "abc-123" — inherited from parent return "processed" } return "no context"}Parent-Child Cancellation
Section titled “Parent-Child Cancellation”async function parent(): Task<string> { var child1: Task<int> = spawn work(1) var child2: Task<int> = spawn work(2)
// If parent is cancelled, child1 and child2 are // automatically cancelled too var r1: int = await child1 var r2: int = await child2 return "done"}
async function work(id: int): Task<int> { return id * 10}When to Use What
Section titled “When to Use What”| Pattern | Use When |
|---|---|
await fn() | You need the result before continuing |
spawn fn() | You want to run CPU-bound work in parallel |
task.cancel() | You want to stop a task and its children |
task.timeout(ms) | You want automatic cancellation after a deadline |
Task.current() | You need to access the current task’s context |
ctx.withValue(k,v) | You want to pass data down the task tree |
ctx.value(k) | You want to read data from parent contexts |
For parallel computing benchmarks comparing Chuks against Go, Java, Bun, Node.js, and Python, see the Parallel Computing guide.