← Code Compare

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.

Show: gujiGoOCamlPerlRakuRustPython
guji
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.

Go
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
(* 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" total

OCaml 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.

Perl
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.

Raku
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.

Rust
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.

Python
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).