OxigenLang Concurrency
OxigenLang runs parallel work on real OS threads across all your cores — not single-threaded async. The model is share-nothing: each task runs on its own VM with its own heap, and data crosses between threads by ownership transfer, not shared references. There are no locks, no mutexes, and no data races by construction.
The surface is two verbs — diverge to split work off the current flow, and converge to bring a result back.
#diverge / converge
#One task off to the side
diverge { ... } runs a block on a worker thread and immediately returns a task handle. Your code keeps going. converge waits for the task and gives you its value.
work := diverge { expensive() } // starts running in the background
// ... do other things here while it runs ...
result := converge work // wait for it, get the valueconverge is idempotent — joining the same task twice returns the same value (it is remembered after the first join).
#Fan out over a collection
diverge each X in XS { ... } runs the block for every item in parallel and gathers the results into an array, in input order.
nums := [1, 2, 3, 4, 5, 6]
squares := diverge each n in nums {
n * n
}
// squares == [1, 4, 9, 16, 25, 36]Every iteration is in flight at once, so the wall-clock time is roughly one item's work, not the sum. This is the idiomatic way to parallelize a loop.
#Async API calls
Network calls in the net library are blocking — net.get(url) returns once the response arrives. You make them asynchronous by running them with diverge: a blocked worker stalls one thread, not your program. There is no async/await and no function coloring.
introduce net
urls := [
"https://api.example.com/users/1",
"https://api.example.com/users/2",
"https://api.example.com/users/3",
]
// all three requests run at the same time
responses := diverge each url in urls {
net.get(url)
}
each r in responses {
println(r["status"])
}Three sequential calls would take three round-trips; this takes one. The same pattern works for net.post, net.put, sockets, file I/O, or any blocking call.
#Timeouts
Add within <ms> to a converge to bound the wait. On timeout it returns an error value you can test with is_error; the task itself keeps running.
work := diverge { slow_job() }
result := converge work within 500 // wait at most 500ms
answer := option {
is_error(result) -> "still working, gave up waiting",
result
}#Cancellation
cancel(task) asks a task to stop. Cancellation is cooperative — the task stops at its next function call or loop iteration, JIT-compiled loops included. Joining a cancelled task returns an error value (test it with is_error).
work := diverge { long_running() }
cancel(work)
result := converge work
done := option {
is_error(result) -> "was cancelled",
result
}#Joining a batch
converge also accepts a list of tasks and joins them all, in order — no import needed. Use it to start several different tasks and collect them together:
user := diverge { get_user(42) }
orders := diverge { get_orders() }
results := converge [user, orders] // [userResult, ordersResult]For fanning out the same work over a collection, use diverge each.
#Passing messages with channels
A task's result comes back once, when it finishes. To pass values while it
runs, such as jobs for a worker to process in order, use a
channel: a queue that any task can send to and
recv from.
introduce channel
sentences := channel.new()
speaker := diverge {
spoken := 0
repeat when True {
s := channel.recv(sentences) // waits for the next one
stop when s == None // the channel was closed
println("speaking: " + s)
spoken = spoken + 1
}
spoken
}
each s in ["Hello.", "How are you?", "Goodbye."] {
channel.send(sentences, s)
}
channel.close(sentences)
println(converge speaker) // 3Values are delivered in the order they were sent, and each is copied, like
task arguments. A channel is an int handle, so it can be captured by a task
or passed to one. channel.recv(ch, timeout_ms) stops waiting after a timeout,
and channel.try_recv(ch) never waits. Signals reach a program the same way,
on a channel from os.signals.
#What can cross threads
When a value is passed into a task or returned from one, it is copied across the thread boundary (the original side keeps its own copy). These types can cross:
- Primitives: integers, floats, booleans,
None - Strings
- Arrays, tuples, maps, sets (including nested)
- Structs and enums
- Open TCP/UDP sockets (so a server can
divergeeach connection to a handler) - Channels, and processes started with
os.spawn: like sockets, these are handles, so every task that has one uses the same channel or process
#Functions do not cross
A function is data everywhere else in Oxigen, but it is the one thing a task
cannot carry across the boundary. The block you hand to diverge is not copied —
the worker rebuilds it from the program source — so a value that happens to be
a function has nothing to rebuild from.
Three ways to try it, and what each does:
fun handler(x) { x * 2 }
// 1. Captured by the block — raises immediately.
t := diverge { handler_local(3) }
// spawn(): capture cannot send value of type FUNCTION across threads
// 2. Passed as an argument — raises immediately.
t := __spawn(run_it, handler)
// spawn(): cannot send value of type FUNCTION across threads
// 3. Inside a map or array the block reads — the quiet one.
table := {"a": handler}
t := diverge { keys(table) }
converge t // error: undefined variable: tableThe third is the one to watch for. A collection holding a function is dropped from the snapshot the worker receives rather than refused, so the failure surfaces later as an undefined variable naming a variable you can plainly see defined above.
What to do instead: send names, not functions. A worker VM registers every
top-level declaration in your program, so top-level functions — including
name := fun(x) { ... } bindings — already exist on it. Build the table that
holds them inside the task:
handler_a := fun(req) { "A" }
handler_b := fun(req) { "B" }
fun dispatch(which, req) {
table := {"a": handler_a, "b": handler_b} // built on the worker
table[which](req)
}
results := diverge each name in ["a", "b"] {
dispatch(name, "req") // only strings cross
}This is why api has you register routes inside a function
that each worker calls, instead of configuring one app and handing it over.
#Limitations
The model is built for parallel compute and I/O fan-out into the hundreds. A few current edges to know:
- One OS thread per in-flight task. Excellent to a few hundred concurrent tasks; there is no event-loop reactor yet, so this is not tuned for tens of thousands of idle sockets.
- Workers always use the default tiered JIT. They ignore the main process's
--jit/--no-jitflags; heavy per-task loops still JIT on their own. - Auto-join only triggers on
+today. A task used ina + bresolves automatically, but other operators (*,==), indexing, andprintlnstill need an explicitconverge. - Structured
converge { ... }blocks are not implemented yet — useconverge <task>ordiverge each. - Keep tasks as leaves. A task's own
divergeruns inline on its worker, and tasks may nest at most 64 deep; past that the innermostdivergereturns an error value. Have a task do work and return, rather than orchestrate more tasks. - Each task imports the modules it uses afresh. A task gets its own copy of every module it reaches, with fresh module state, so a module's top level runs again for each task that uses it. For thousands of tiny tasks that each touch a large module, give each task a batch of items instead.
#Quick reference
| Form | Meaning |
|---|---|
diverge { B } | run B on a worker, return a task |
diverge each X in XS { B } | run B for each item in parallel, gather results |
converge T | wait for task T, return its value |
converge [T1, T2, …] | wait for a list of tasks, return their values in order |
converge T within ms | wait up to ms; error value on timeout (test with is_error) |
cancel(T) | ask task T to stop (cooperative) |
channel.send(C, V) / channel.recv(C) | pass V to whichever task receives from channel C |