diff --git a/_02_.Writing-programs.sz b/_02_.Writing-programs.sz index 8fa1f90..01aa8e8 100644 --- a/_02_.Writing-programs.sz +++ b/_02_.Writing-programs.sz @@ -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.