Skip to content

Threads and channels

Beans uses real OS threads (there are no green threads). This page walks through threads.b and then shows how full struct and enum values travel across channels (wide_concurrency.b) and through a mutex (wide_sync.b).

The three concurrency tools are Thread, Channel, and Mutex, from std.thread.

Most of threads.b is a recap of the language (generics, enums, Option, decimal); the concurrency part is at the end.

import std.thread
let t: Thread<int> = thread.spawn(fn() -> int {
return 21 * 2
})
io.println(t.join())

thread.spawn takes a function and runs it on a new OS thread. It returns a Thread<int> because the function returns int. t.join() waits for the thread to finish and hands back its return value (42).

let shared: Mutex<Counter> = new Mutex(new Counter("shared"))
shared.with_lock(fn(c: Counter) {
c.bump(5)
})

A Mutex<Counter> wraps a value so only one thread touches it at a time. You do not lock and unlock by hand. You call with_lock with a function, and the lock is held for exactly that function and released after. The locked value is passed in as c.

let ch: Channel<string> = new Channel(8)
ch.send("job")
defer ch.close()

A Channel<string> is a queue between threads. new Channel(8) makes one with room for 8 buffered messages. send puts a value in. defer ch.close() closes the channel when the current scope ends; defer runs its statement on the way out, no matter how you leave.

Run it:

Terminal window
beansc run examples/threads.b

Wide values across a channel: wide_concurrency.b

Section titled “Wide values across a channel: wide_concurrency.b”

A “wide” value is a full value type: a struct or an enum with a payload, rather than a single number or pointer. wide_concurrency.b shows these travel across channels and thread boundaries intact.

import std.io
import std.thread
struct Event {
label: string
value: int
}
fn send_one(ch: Channel<Event>, e: Event) {
ch.send(e)
}
fn main() {
let messages: Channel<Event> = new Channel(1)
let sender: Thread<[i64; 2]> = thread.spawn(fn() -> [i64; 2] {
send_one(messages, Event { label: "from worker", value: 41 })
return [7, 8]
})
let event: Event = messages.receive().expect("event")
let result: [i64; 2] = sender.join()
io.println("channel {event.label} {event.value}")
io.println("thread array {result[0]} {result[1]}")
messages.close()
io.println("channel closed {messages.receive().is_none()}")
}

Things to notice:

  • The channel carries a whole Event struct. receive() returns an Option<Event>, and .expect("event") unwraps it or panics with that message.
  • A thread can return a fixed-size array ([i64; 2]); join() gives it back.
  • After close(), receive() returns none, so .is_none() is true. That is how a reader learns the channel is done.

The example also sends int, decimal, and even a Result<Pair> through channels; any type works:

let guarded: Channel<Result<Pair>> = new Channel(1)
guarded.send(err("channel error"))
match guarded.receive().expect("result") {
ok(pair) => { io.println("bad {pair.left}") },
err(error) => { io.println("channel result {error.msg}") },
}

Run it:

Terminal window
beansc run examples/wide_concurrency.b

wide_sync.b does the same for shared state: it puts wide values behind Shared, Weak, and Mutex.

let mutex: Mutex<Event> = guard(Event { label: "locked", value: 9 })
mutex.with_lock(fn(value: Event) {
io.println("mutex {value.label} {value.value}")
})

with_lock hands the guarded Event to the function while the lock is held.

let worker: Thread<int> = thread.spawn(fn() -> int {
let event: Event = shared.get()
return event.value + 1
})
io.println("thread {worker.join()}")

Shared<T> is a reference-counted value many threads can hold; shared.get() reads it. The file also shows Weak<T> (a non-owning reference that can expire) via downgrade(), upgrade(), and is_expired().

Run it:

Terminal window
beansc run examples/wide_sync.b

Atomics covers lock-free shared cells with explicit ordering, and the concurrency guide is the full picture. Ownership handles documents Shared, Weak, Box, and friends.