Concurrency: threads, goroutines & channels
The same job in all seven languages: fan out five concurrent workers, each computing the square of a number, send each result over a channel (or queue), and have the main task collect and sum them (answer: 55). Watch the two big axes: how a task is spawned (hatch/go/thread/async/Thread) and how results travel back — guji and Go pass them through a typed channel with no shared mutable state, Rust moves ownership across a mpsc channel, while Perl and Python hand them through a thread-safe queue. Notice who has to close the channel and who joins the workers before reading the total.
“I think that the way to make progress is to encourage people to be a little more sloppy in their thinking, and a little more rigorous in their implementation.” — Larry Wall
sub main(): Int {
$results: Chan[Int] = channel()
# One task does all the sends, then closes the channel.
# Captured @nums is immutable, so no task can race on it.
@nums = [1, 2, 3, 4, 5]
hatch {
for $n in @nums {
$results.send($n * $n) # squares cross the channel
}
$results.close() # signal "no more values"
}
# `for` over a channel drains it until closed (§17.4).
mut $total = 0
for $sq in $results {
$total = $total + $sq
}
print("sum of squares: $total")
0
}guji's concurrency is Go's CSP with one twist: every value crossing a channel is immutable, so tasks share only by communicating and data races are structurally impossible (§17). hatch { ... } spawns a fire-and-forget task that captures the immutable @nums; results flow back through the typed Chan[Int]. send returns a Result and recv an Option (no Go-style panic on a closed channel), and for over the channel receives until it is closed and drained. Concurrency is specified but reserved for post-v0, so this does not yet run on the v0 evaluator.
package main
import (
"fmt"
"sync"
)
func main() {
results := make(chan int)
var wg sync.WaitGroup
// One goroutine per number; each sends its square.
for n := 1; n <= 5; n++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(n)
}
// Close the channel once every worker is done so range ends.
go func() { wg.Wait(); close(results) }()
total := 0
for v := range results {
total += v
}
fmt.Printf("sum of squares: %d\n", total)
}The model guji borrows from: go func(){...}() launches a lightweight goroutine and an unbuffered chan int carries results back, with no locks around the shared data. Because each worker runs independently, a sync.WaitGroup tracks completion and a separate goroutine closes the channel only after wg.Wait(), which is what lets the range results loop terminate cleanly. Passing n as an argument captures it per-iteration — a classic Go gotcha before the loop-variable change in Go 1.22.
(* OCaml 5: effects-based domains + a Mutex-guarded Queue as the channel. *)
let () =
let results = Queue.create () in
let mtx = Mutex.create () in
let send v =
Mutex.lock mtx; Queue.push v results; Mutex.unlock mtx in
(* Spawn one domain per number; each sends its square. *)
let workers =
List.map (fun n -> Domain.spawn (fun () -> send (n * n)))
[1; 2; 3; 4; 5]
in
List.iter Domain.join workers;
let total = Queue.fold ( + ) 0 results in
Printf.printf "sum of squares: %d\n" totalOCaml 5 introduced true parallelism via domains (Domain.spawn / Domain.join), one OS thread each, replacing the old single-threaded Thread cooperative model. There is no built-in typed channel like guji's, so a shared Queue guarded by a Mutex plays that role, and Domain.join provides the barrier before the main domain reads. The standard-library Domainslib adds higher-level channels and a task pool, but the primitives shown here are what they are built on.
use strict;
use warnings;
use threads;
use Thread::Queue;
my $results = Thread::Queue->new;
# Spawn one thread per number; each enqueues its square.
my @workers = map {
my $n = $_;
threads->create(sub { $results->enqueue($n * $n) });
} 1 .. 5;
$_->join for @workers; # wait for every worker
my $total = 0;
$total += $results->dequeue for 1 .. 5;
print "sum of squares: $total\n";Perl's threads are real interpreter threads: each gets its own copy of the interpreter, so nothing is shared unless you make it so. Thread::Queue is the thread-safe channel here — workers enqueue their squares and the main thread dequeues them after joining every worker. This iThreads model is heavyweight and now discouraged for new code (the docs steer you toward forking or event loops), but it is the direct in-language analogue of channels and joins.
my @nums = 1, 2, 3, 4, 5;
# `start` returns a Promise; the block runs on the thread pool.
my @promises = @nums.map(-> $n { start { $n * $n } });
# await blocks until all Promises are kept, returning their values.
my $total = await(@promises).sum;
say "sum of squares: $total";Raku leans on promises and a thread pool rather than raw threads: start { ... } schedules the block and immediately returns a Promise, and await collects all their results once kept. For streaming work Raku also offers Channel (an unbuffered queue, the closest match to guji's Chan) and reactive Supplys, but for a fixed fan-out-and-gather like this, start/await is the idiomatic high-level spelling. The whole pipeline reads as an ordinary .map over the numbers.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
// One thread per number; each moves a sender clone and sends its square.
for n in 1..=5 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
drop(tx); // drop the original so rx ends once all clones are gone
let total: i32 = rx.iter().sum();
println!("sum of squares: {total}");
}Rust pairs thread::spawn with a multi-producer, single-consumer mpsc::channel. Each thread takes a cloned Sender via a move closure, transferring ownership so the borrow checker guarantees no data race at compile time. The receiver's rx.iter() yields values until every Sender (clones plus the original, which we drop) is gone — the ownership-based equivalent of guji or Go closing the channel. No join is needed because draining the receiver is itself the synchronization point.
import threading
import queue
results = queue.Queue()
def worker(n):
results.put(n * n) # send the square through the queue
# Spawn one thread per number, then join them all.
threads = [threading.Thread(target=worker, args=(n,)) for n in range(1, 6)]
for t in threads:
t.start()
for t in threads:
t.join()
total = sum(results.get() for _ in range(5))
print(f"sum of squares: {total}")Python's threading.Thread runs each worker and a thread-safe queue.Queue is the channel back to the main thread. Joining every thread before draining the queue is the explicit synchronization. Note the caveat: CPython's GIL means these threads do not run Python bytecode in parallel, so threads help with I/O but not CPU-bound work — for real parallelism you would reach for multiprocessing or concurrent.futures.ProcessPoolExecutor (and Python 3.13+ ships an experimental free-threaded, GIL-free build).