diff --git a/_02_.Writing-programs.sz b/_02_.Writing-programs.sz index f1e8902..8a9b9de 100644 --- a/_02_.Writing-programs.sz +++ b/_02_.Writing-programs.sz @@ -789,6 +789,109 @@ Conversely, you may occasionally need to wait on a channel or event from inside (sync/blocking (chan-recv ch))) }| +@subsubsection{Shared state: atomics and transactions} + +Channels move values from one fiber to another. When many fibers must read and change the same data, a channel means one fiber that owns the data and answers requests for it. That is often the clearest design, but it is not the only one. Two modules let fibers share data directly: + +@read-table{ +| Module | What it is for | +|--------------------+-----------------------------------------------------------------------------------| +| @code{(std atomic)} | One value that threads change without a lock: a counter, a flag, a cached result | +| @code{(std stm)} | Changes to several values as one step, and waiting until the values are right | +} + +An atomic is the simple case and is described in Part III. The rest of this section is about transactions: software transactional memory, built on the design of the OCaml library kcas. + +@strong{Locations and transactions.} A @code{(Loc T)} holds one value. A transaction is a function of one argument, a @code{Tx}, which is the log of the transaction. The function reads and writes locations through the log with @code{tx-ref} and @code{tx-set!}, and @code{atomically} runs it and commits it: + +@codeblock[#:lang "bjolang"]|{ +(import (std stm)) + +(def pot (make-loc 0)) +(def table (make-loc (list))) + +;; Put a card on the table and add its points to the pot, as one step. +(: play! (-> Card int void)) +(defun (play! card points) + (atomically + (fun (tx) + (tx-set! tx table (Cons card (tx-ref tx table))) + (tx-set! tx pot (+ (tx-ref tx pot) points))))) +}| + +The writes stay in the log until the transaction commits, and the commit makes all of them visible at once. No other fiber sees the card on the table without its points in the pot. If another transaction changed a location that this one read before this one commits, this one runs again with the new values. So a transaction never acts on values that changed under it, and nothing is ever locked. + +@strong{The rules for a transaction function.} Because it can run more than once, a transaction function must have no side effects except through its @code{Tx}: no printing, no I/O, no changes to other state. Do those after @code{atomically} returns. A transaction function also can not @code{sync}. It is an ordinary function, and the compiler says so: + +@codeblock[#:lang "text"]|{ +Type Error at play.bjo:7: calling 'sync' is a yield point, and a yield point is not allowed here. +}| + +If a transaction raises an exception, it writes nothing, and @code{atomically} raises the exception. Store immutable values in locations: a change inside a mutable vec is not part of any transaction. + +@strong{Waiting with tx-retry.} A transaction that can not go on yet calls @code{tx-retry}. That stops it, and it runs again when a location that it read has changed. There is no condition variable to signal and no wakeup to forget: + +@codeblock[#:lang "bjolang"]|{ +;; The top card of a pile. Waits while the pile is empty. +(: take-top (-> Tx (Loc (List Card)) Card)) +(defun (take-top tx pile) + (match (tx-ref tx pile) + ((Cons card rest) (tx-set! tx pile rest) card) + (Nil (tx-retry tx)))) +}| + +@strong{A transaction is an event.} @code{(atomically-evt f)} is the event that runs and commits @code{f}. While @code{f} retries, the event is not available. So waiting for a condition on shared data combines with everything else that @code{choose} combines: a timeout, a channel, another transaction, and the cancellation of the fiber's scope. A transaction whose branch loses has written nothing. @code{atomically} is @code{(sync (atomically-evt f))}: + +@codeblock[#:lang "bjolang"]|{ +;; A card from the pile, or None after 100 ms. +(sync (choose (wrap (atomically-evt (fun (tx) (take-top tx pile))) Some) + (wrap (timeout 100) (fun (_) None)))) +}| + +Inside a transaction, @code{(tx-or-else tx a b)} runs @code{a}, and if @code{a} retries, undoes its writes and runs @code{b}. If both retry, the whole transaction waits for a change to anything either of them read. + +@code{atomically} works in both colours: in a bjoroutine it syncs, and in an ordinary function it blocks the thread. @code{try-atomically} never waits: it gives @code{None} when the transaction retries, which makes it safe in a callback that .NET calls. + +@strong{Queues, maps and counters.} Three modules build data structures out of locations, so that their operations are part of the transaction that calls them: + +@read-table{ +| Module | Type | What it is | +|----------------------------+--------------------+--------------------------------------------------------------------| +| @code{(std stm queue)} | @code{(TQueue T)} | A FIFO queue, bounded or not. Producers and consumers seldom conflict | +| @code{(std stm map)} | @code{(TMap K V)} | A hash map. Changes to different keys seldom conflict | +| @code{(std stm counter)} | @code{(TCounter T)} | A number that fibers on different CPUs add to without conflicts | +} + +Their operations take the @code{Tx} first. Here a dealer takes the top card of a shared deck into a player's hand, and counts it, in one step: + +@codeblock[#:lang "bjolang"]|{ +(import (std stm queue)) +(import (std stm map)) +(import (std stm counter)) + +(def deck (make-queue)) ;; a (TQueue Card) +(def hands (make-tmap)) ;; a (TMap string (List Card)) +(def dealt (make-tcounter 0)) ;; a (TCounter int) + +;; Waits while the deck is empty: queue-pop! retries. +(: deal! (-> Tx string Card)) +(defun (deal! tx player) + (def card (queue-pop! tx deck)) + (tmap-update! tx hands player + (fun (hand) (Some (Cons card (match hand ((Some cards) cards) (None Nil)))))) + (tcounter-add! tx dealt 1) + card) + +(: dealer (-bjo-> string int void)) +(defbjo (dealer player n) + (loop (:for i (range 0 n)) + (:do (ignore (atomically (fun (tx) (deal! tx player))))))) +}| + +Two dealers can run at the same time. Neither card can end up in two hands, or be counted without being dealt. The queue also has the channel names for use outside a transaction: @code{queue-send} and @code{queue-recv} are events, and @code{queue-put} and @code{queue-get} sync them. What a queue adds to a channel is the transaction: take from one queue and put in another, or take from whichever of two queues has a card, as one step. + +@strong{Costs.} A transaction that reads and writes the same locations as many others runs again often. Many fibers that write one location cost the most. A @code{TMap} or @code{TCounter} spreads the writes over many locations for that reason. A transaction that only reads never makes another one run again. Transactions also allocate, and a program in which many fibers allocate at once is often limited by the garbage collector: set @code{(runtime (gc-server #t))} in the manifest to give it one heap per CPU. With it, sixteen fibers that add keys to one @code{TMap} have eight times the throughput of one fiber. + @subsubsection{Compared with Go and Hopac} Go is the obvious point of comparison: goroutines and unbuffered channels are the same model of fibers meeting at a rendezvous. The main 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: as plain channel operations, and with each one selecting against @code{ctx.Done()}, which is what a go program has to stop execution. 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 bjolang columns is the cost of 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: diff --git a/_03.Standard-library.sz b/_03.Standard-library.sz index 1af05eb..1db6bb6 100644 --- a/_03.Standard-library.sz +++ b/_03.Standard-library.sz @@ -44,6 +44,11 @@ For more details, see @link["modules/std/random.html"]{@code{(std random)}}. An atomic is a cell that threads can read and write without a lock. @code{(make-atomic v)} makes one of any type, and @code{atomic-ref}, @code{atomic-set!}, @code{atomic-swap!}, @code{atomic-compare-and-swap!} and @code{atomic-update!} work on it. For @code{int}, @code{uint}, @code{long} and @code{ulong} there are also @code{atomic-increment!}, @code{atomic-decrement!}, @code{atomic-add!} and @code{atomic-sub!}, which compile to direct calls to @code{System.Threading.Interlocked} and wrap on overflow. Swap and compare-and-swap return the value from before the call; the other operations that change the atomic return the new value. The width of a counter comes from type inference, so write @code{(make-atomic 0L)} for a long counter: @code{(make-atomic 0)} is an int. For more details, see @link["modules/std/atomic.html"]{@code{(std atomic)}}. +@subsection{std/stm} + +Software transactional memory, after the OCaml library kcas. A @code{(Loc T)} holds one value, and a transaction is a function of a @code{Tx} that reads and writes locations with @code{tx-ref}, @code{tx-set!}, @code{tx-update!} and @code{tx-swap!}. @code{atomically} runs it and commits all of its writes as one step, and runs it again if another transaction changed what it read. @code{tx-retry} stops a transaction until a location that it read changes, and @code{atomically-evt} is the transaction as a CML event, so waiting for a condition combines with timeouts, channels and cancellation in a @code{choose}. @code{tx-or-else} tries alternatives. Three modules build data structures out of locations: @code{(std stm queue)} has the FIFO queue @code{TQueue}, with the channel names @code{queue-send}, @code{queue-recv}, @code{queue-put} and @code{queue-get} outside a transaction; @code{(std stm map)} has the hash map @code{TMap}, whose operations are named as those of the prelude's @code{Map}; and @code{(std stm counter)} has @code{TCounter}, a number that fibers on different CPUs add to without conflicts. Part II has a guide in the section on concurrency. +For more details, see @link["modules/std/stm.html"]{@code{(std stm)}}, @link["modules/std/stm/queue.html"]{@code{(std stm queue)}}, @link["modules/std/stm/map.html"]{@code{(std stm map)}} and @link["modules/std/stm/counter.html"]{@code{(std stm counter)}}. + @subsection{std/datetime} This module provides types and functions for handling time. The clock is an argument (with a default that a program can rebind), and the types refuse operations that mean nothing, such as adding a calendar day to an instant. The module separates @code{Date} (a calendar date without a timezone), @code{Time} (a time of day without a timezone), and @code{DateTime} (a date and time without a timezone), as well as handling timezones. diff --git a/modules/std/stm.sz b/modules/std/stm.sz new file mode 100644 index 0000000..8173d54 --- /dev/null +++ b/modules/std/stm.sz @@ -0,0 +1,3 @@ +(meta (title "(std stm)")) + +@generate-docs[#:module "std stm"] diff --git a/modules/std/stm/counter.sz b/modules/std/stm/counter.sz new file mode 100644 index 0000000..e71480c --- /dev/null +++ b/modules/std/stm/counter.sz @@ -0,0 +1,3 @@ +(meta (title "(std stm counter)")) + +@generate-docs[#:module "std stm counter"] diff --git a/modules/std/stm/index.sz b/modules/std/stm/index.sz new file mode 100644 index 0000000..5742930 --- /dev/null +++ b/modules/std/stm/index.sz @@ -0,0 +1,2 @@ +(meta (title "std stm: transactional data structures") + (sort title)) diff --git a/modules/std/stm/map.sz b/modules/std/stm/map.sz new file mode 100644 index 0000000..6b28043 --- /dev/null +++ b/modules/std/stm/map.sz @@ -0,0 +1,3 @@ +(meta (title "(std stm map)")) + +@generate-docs[#:module "std stm map"] diff --git a/modules/std/stm/queue.sz b/modules/std/stm/queue.sz new file mode 100644 index 0000000..ff70c0d --- /dev/null +++ b/modules/std/stm/queue.sz @@ -0,0 +1,3 @@ +(meta (title "(std stm queue)")) + +@generate-docs[#:module "std stm queue"]