chan-put and chan-get, and Bjolang's concurrency compared with Go

The channels section introduces chan-put and chan-get beside the event
forms. A new section compares the CML benchmarks with their Go twins, with
Bjolang measured both syncing events and with chan-put/chan-get and Go both
plain and racing ctx.Done(), and says what Bjolang has that Go does not:
events as values and structured scopes.

🤖 Generated with [ECA](https://eca.dev)

Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
Linus Björnstam 2026-10-06 08:22:12 +02:00
parent 96d6ebc8e2
commit 554d74b7f2

View file

@ -688,6 +688,14 @@ Notice that @code{within} is defined with standard @code{defun} rather than @cod
Also notice how the first timeout behaves: when the 50 ms deadline expires before the card arrives, the @code{chan-recv} event is cleanly cancelled without consuming the message. The card remains on the channel, available to be received by the subsequent 500 ms wait. Also notice how the first timeout behaves: when the 50 ms deadline expires before the card arrives, the @code{chan-recv} event is cleanly cancelled without consuming the message. The card remains on the channel, available to be received by the subsequent 500 ms wait.
An operation that is synced right where it is written does not need to be an event first. @code{(chan-put ch v)} does what @code{(sync (chan-send ch v))} does, and @code{(chan-get ch)} what @code{(sync (chan-recv ch))} does, in the way Guile's fibers have @code{put-message} beside @code{put-operation}. They wait, and are cancelled, exactly as @code{sync} is, but no event value is built, so they are cheaper. Use the event forms when you need an event: to @code{choose} between operations, to @code{wrap} one, or to hand one to a function such as @code{within}. Our players could just as well be written with @code{chan-put}:
@codeblock[#:lang "bjolang"]|{
(defbjo (player hand seat)
(loop (:for c (list-reverse (list-sort (vec->list hand))))
(:do (chan-put seat c))))
}|
@subsubsection{Collecting fiber results: bjo} @subsubsection{Collecting fiber results: bjo}
While @code{spawn} runs a fiber for its side effects and discards its return value, @code{bjo} returns a joinable promise. You can await this promise using @code{(promise-join p)}, which yields a @code{Result} wrapping either the computed value or an exception if the fiber failed: While @code{spawn} runs a fiber for its side effects and discards its return value, @code{bjo} returns a joinable promise. You can await this promise using @code{(promise-join p)}, which yields a @code{Result} wrapping either the computed value or an exception if the fiber failed:
@ -781,6 +789,25 @@ Conversely, you may occasionally need to wait on a channel or event from inside
(sync/blocking (chan-recv ch))) (sync/blocking (chan-recv ch)))
}| }|
@subsubsection{Compared with Go}
Go is the obvious point of comparison: goroutines and unbuffered channels are the same model of fibers meeting at a rendezvous. The repository has the same benchmarks written in both languages (@code{bench/bjolang/cmlbench.bjo} and @code{go run . -suite cmlbench} in @code{bench/go}). Every Bjolang channel operation races the cancellation token of its scope, so Go is measured twice where that matters: as plain channel operations, and with each one selecting against @code{ctx.Done()}, which is what a Go program that can be stopped has to write. Bjolang is measured twice as well: with events and @code{sync}, and with @code{chan-put} and @code{chan-get}, so the difference between the two columns is what building an event and syncing it costs. The best of five runs, in nanoseconds per operation, on a Ryzen 9 5900X under Linux with .NET 10 and Go 1.25:
@read-table{
| Benchmark | Bjolang, @code{sync} | Bjolang, @code{chan-put} | Go | Go, with @code{ctx.Done()} |
|----------------------------------------+----------------------+--------------------------+-----+----------------------------|
| Ring: 1000 fibers, a million messages | 86 | 68 | 114 | 241 |
| The same ring inside a nested scope | 73 | — | — | 245 |
| Spawning a million fibers and waiting | 84 | — | 132 | — |
| An 8-way choose where one branch fires | 152 | 153 | 400 | 443 |
}
In the choose row only the sender can use @code{chan-put}, since a choice between operations is what an event is for, and the time is set by the receiving @code{choose}. On the ring, syncing an event costs about a fifth of each message.
These are small benchmarks on one machine, and they measure the cost of meeting at a channel, not of the work between meetings. Go is ahead in other ways: a Go channel operation never allocates, where a Bjolang send synced as an event allocates 32 bytes and a @code{choose} 40 (@code{chan-put} and @code{chan-get} allocate nothing); any Go function can block without being written as a bjoroutine; and the Go scheduler preempts a goroutine that computes for too long, where a fiber has to be moved off the pool with @code{spawn/thread}.
What Bjolang offers that Go does not is in the language rather than in the numbers. A Go @code{select} is a statement: its cases are fixed in the source, and a channel operation cannot be passed to a function, wrapped, or combined with others. In Bjolang an event is a value, which is what made @code{within} above a two-line function that works on any event, and @code{choose}, @code{wrap}, @code{guard} and @code{with-nack} build new events out of old ones. Go has no structured concurrency either. Waiting for goroutines, stopping them, and reporting their failures are a @code{sync.WaitGroup}, a @code{context.Context} passed to every function and checked at every channel operation, and an @code{errgroup}, written by hand each time. A goroutine that nothing waits for leaks without a sound. In Bjolang a scope does all of that by construction: it waits for its fibers, raises their failures when it ends, releases the files they opened, and cancels them through a token that every @code{sync} watches without being asked.
@subsection{Effects} @subsection{Effects}
Our deck is shuffled using @code{shuffle-vec}, which is non-deterministic. While randomness is ideal during gameplay, it makes automated tests brittle when verifying deterministic outcomes like trick winners. The traditional object-oriented or functional workaround is dependency injection—passing an explicit shuffle function down through every layer of the call stack. Our deck is shuffled using @code{shuffle-vec}, which is non-deterministic. While randomness is ideal during gameplay, it makes automated tests brittle when verifying deterministic outcomes like trick winners. The traditional object-oriented or functional workaround is dependency injection—passing an explicit shuffle function down through every layer of the call stack.