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:
parent
96d6ebc8e2
commit
554d74b7f2
1 changed files with 27 additions and 0 deletions
|
|
@ -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.
|
||||
|
||||
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}
|
||||
|
||||
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)))
|
||||
}|
|
||||
|
||||
@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}
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue