Documentation

Network2Go.PlusCal

Compiling Network PlusCal processes into Go, on top of the TLA⁺ half (Network2Go.{Typ,Expression,Definition}) and the lock assignment (Network2Go.Locks).

The shape. An atomic block does not become a Go block. Each of its branches becomes a top-level function returning bool — "did I fire" — and the block itself becomes a scheduler function looping over them, picking one at random per iteration and stopping once one returns true. Control then leaves through goto, which spawns a goroutine running the next block's scheduler rather than calling it: a chain of blocks is unbounded, and Go's goroutine stacks are small and growable, so a tail call would eventually overflow one.

The loop is a busy-wait, and knowingly so: a failed iteration still pays for its lock acquisitions and its guard evaluation. Condition variables would avoid that and are much harder to state a correctness property about. Go's runtime also preempts on channel operations, which is exactly what Acquire/Release are, so the loop does not spin freely in practice.

Every function takes every lock, whether it acquires it or not, because a goto may hand control to a block with a different footprint and the lock has to reach it. Which locks a piece of code acquires is decided per branch, not per block: two branches of one block touching disjoint variables should not serialize against each other.

Locks are storage, not just mutual exclusion. A process-local variable exists only inside the struct its lock carries; a branch projects the variables out after Acquire and reassembles them before Release, and INIT_LOCKS in the process function is the only place an initial value is ever written. This is why Network2Go.Locks does not prune thread-confined locks.

Four choices are this compiler's own:

Go fragments used throughout #

Go's unit type, struct {} — the element type of every done channel here.

Equations
Instances For

    Go's own bool, as opposed to the runtime's tlaplus.Bool. A branch's guard and a scheduler's shouldContinue are Go booleans: they drive if and for, which the runtime type cannot.

    Equations
    Instances For

      locks.f(e₁, …).

      Equations
      Instances For

        condlocks.f(e₁, …).

        Equations
        Instances For

          Per-process environment #

          Everything the compilation of one process's blocks needs to agree on: the names its generated functions bind, and the lock assignment they all share.

          self is not freshly named. A compiled expression mentioning self goes through binderName, so the parameter has to answer to exactly that; the elaborator binds self rather than declaring it, so no process-local variable can collide with it. net and done are fresh, since nothing in a compiled expression refers to them and a process variable named net is perfectly legal.

          • proc : String

            The process's source-level name, which every synthesized function name is qualified by.

          • locks : ProcessLocks
          • Declared process-local variables and their types, in declaration order.

          • Every channel of the whole specification and the type it carries — the Network struct is algorithm-wide, so a process may send/multicast on one another process declared. Needed where a channel's element type cannot be read off the statement: multicast's payload compiles to a function literal, and Go requires a literal to state its result type.

          • net : String
          • self : String
          • done : String
          • condBackend : Bool

            Whether this process compiles under the experimental -Xgo-cond backend rather than the default busy-wait one. The two share every field above — lock inference does not care how a lock is represented at runtime — and differ only in which package a lock's type/calls name and in how an atomic block's branches are scheduled.

          Instances For

            The struct {x τ, …} a lock carries: one field per variable it guards, in declaration order.

            The field order is fixed by Lock.vars rather than sorted, unlike a compiled record type. Nothing structural depends on it here — this type is written out at every site that mentions the lock, and all of them get it from this function.

            Equations
            • One or more equations did not get rendered due to their size.
            Instances For

              The Go type of one lock: locks.Lock[struct {x τ, …}], or condlocks.Lock[struct {x τ, …}] under -Xgo-cond.

              Equations
              Instances For

                Acquire respelled for whichever lock representation env selects: locks.Acquire(ℓ), a free function, by default; ℓ.Acquire(), a method, under -Xgo-cond, whose Lock carries an extra change signal Acquire itself never touches.

                Equations
                Instances For

                  Release respelled the same way — under -Xgo-cond this is always the broadcasting release, ℓ.Release(v); a guard-false attempt there calls ReleaseNoBroadcast directly instead of this.

                  Equations
                  Instances For

                    MkLock respelled for whichever package env selects. Always a free function in both backends — nothing to construct it from yet — so only the package qualifier differs.

                    Equations
                    Instances For

                      ℓ.ReleaseNoBroadcast(v)-Xgo-cond only, the guard-false release that leaves the value unchanged and returns the change signal to wait on next, snapshotted before the release completes.

                      Equations
                      Instances For

                        The lock parameters every generated function takes, in locking order.

                        Equations
                        Instances For

                          The lock arguments, matching lockParams position for position.

                          Equations
                          Instances For

                            The parameter list shared by branch, block-scheduler and thread functions: (ℓ₁, …, ℓₖ, net, self, done).

                            Equations
                            • One or more equations did not get rendered due to their size.
                            Instances For

                              _ = f(args…) — a call whose result is deliberately dropped. Every generated function returns struct {} or bool, and Go rejects a bare call only for the sake of the value, so the blank assignment is what makes go { … } around one legal.

                              Equations
                              Instances For

                                Statements #

                                A branch's guards. await conjoins onto guard unconditionally — its condition is always total, an ordinary TLA⁺ boolean, so evaluating it after guard has already gone false costs nothing but a redundant read. A with is not total: with x = e's e can be a CHOOSE/search with no witness, and with x ∈ e's e can be an empty (or infinite) set — either is undefined exactly when some guard textually before it already made the branch not fire. So only the binder's own evaluation is gated, by the current guard at that point — var x τ (no initializer, nothing to evaluate) sits outside the if, the assignment inside it. Nothing else in the branch needs to nest: guard only ever narrows (&&), never recovers, so a later statement reading guard — the next with's own if, another await, or the branch's final if guard { action } — sees the same false once anything upstream has failed, whether or not this with actually ran. (A later with's e may then read x at its zero value rather than a real one, if guard was already false here — harmless, since that later with is itself gated on the same guard and so skips its own assignment too, never forcing the zero value through anything partial.) One if per with, each independent — not one nested inside the last.

                                with x ∈ e compiles through Pick (runtime/tlaplus/sets.go), the same uniform-random draw a variable x ∈ S initializer already uses (initLocks below): pick now, unconditionally on whatever e denotes at this attempt, and let a guard after this with reject the draw the ordinary way — guard goes false, the branch function returns false, and the block's scheduler (this file's top comment) retries with a fresh iteration, which re-enters this with and draws again. There is deliberately no search for a value satisfying what follows: the specification only requires some run to reach a satisfying draw, and every failed one is exactly as cheap as any other failed guard.

                                Equations
                                • One or more equations did not get rendered due to their size.
                                Instances For

                                  A branch's action statements. These run only once every guard has passed, so they are emitted inside the branch's if guard { … }.

                                  Equations
                                  Instances For

                                    Locks around a branch #

                                    Branches, blocks, threads, processes #

                                    One branch of an atomic block, as its own bool-returning function.

                                    The order is fixed by what depends on what: guard first, then the locks (a guard reads locked variables), then the guards — each compileGuard result appended flat, per its own doc comment — then the body under one if guard, then the releases, then return guard. The releases sit outside the if because the locks were acquired outside it too.

                                    Equations
                                    • One or more equations did not get rendered due to their size.
                                    Instances For

                                      An atomic block: its branch functions, plus the scheduler that picks between them.

                                      shouldContinue = !branch(…) is the whole protocol — a branch returns whether it fired, and the loop stops exactly when one did. With a single branch the switch is redundant (Rand(0, 1) is always 0); the thesis emits it anyway and so does this, leaving the peephole to a later pass rather than special-casing the shape here.

                                      The ToInt around the switch head is load-bearing rather than cosmetic. Rand is typed over the runtime's Int, which under the default arbitrary-precision build is a struct — an Int-valued switch head could not match the integer-literal cases, and Go would reject the function.

                                      Equations
                                      • One or more equations did not get rendered due to their size.
                                      Instances For

                                        Branches and blocks, experimental -Xgo-cond backend #

                                        One branch of an atomic block under -Xgo-cond, as its own struct {}-returning function taking two parameters beyond commonParams: cancel, closed once by whichever branch of the block resolves it, and arbiter, the one-shot arbitration every simultaneously-true branch of the block shares — lock exclusivity alone only serializes branches whose footprints overlap, and says nothing about two branches on disjoint locks that are both true at once.

                                        Loops, unlike compileBranch: a guard-false attempt releases without broadcasting, gets back the change signal ReleaseNoBroadcast snapshots before releasing, and sleeps on it — or on cancel, which every sibling branch not sharing this one's locks would otherwise never wake for — before retrying. The nonblocking recheck right after the sleep is not redundant: select has no priority, so a live change can win the pseudo-random pick over an already-closed cancel, and without the recheck this branch could keep retrying a resolved block for as long as something else keeps touching its locks.

                                        A guard-true attempt always returns, win or lose the arbitration — the block is resolved either way, and a loser must release exactly what it acquired, never running its action.

                                        Equations
                                        • One or more equations did not get rendered due to their size.
                                        Instances For

                                          An atomic block under -Xgo-cond: its branch functions, spawned as goroutines racing on a cancel/arbiter pair fresh to this block instance, plus the function that spawns them and blocks on <-cancel until one resolves the block.

                                          Blocking before returning, rather than returning the moment every branch is spawned, is what keeps this function's calling convention identical to compileBlock's scheduler: compileCodeThread's caller (ultimately compileProcess's thread_k, called synchronously) sees the same run-to-resolution contract from either backend.

                                          Equations
                                          • One or more equations did not get rendered due to their size.
                                          Instances For

                                            A code thread: every block it contains, plus the function that starts the chain by calling the first one. Everything after that first block happens through goto's goroutines, so this really is all a thread needs.

                                            A thread with no blocks compiles to a function that does nothing. That is not a degenerate case to reject: @rx-annotated threads are written {} in the source, and Guarded2Network turns them into Thread.rx — but an ordinary empty thread is legal too, and denotes a process component that terminates immediately.

                                            Equations
                                            • One or more equations did not get rendered due to their size.
                                            Instances For

                                              A receiving thread: loop on mailbox.Recv(), and on each message that arrives, acquire the lock holding inbox just long enough to append.

                                              Locking only around the append is the point. Recv blocks — possibly forever, if no peer ever sends — and a thread that held inbox's lock across that call would freeze every block trying to consume a message, including ones with nothing to consume. ok going false means the medium is gone, which is how the loop terminates instead of blocking against a channel nobody will write to.

                                              This thread takes mailbox in place of done: it never finishes on its own, so it has nothing to signal.

                                              Equations
                                              • One or more equations did not get rendered due to their size.
                                              Instances For

                                                A process's initialization prologue: the declaration walk, then the initial lock values (INIT_LOCKS).

                                                The walk is in declaration order, and interleaves two different things. A @parameter emits no local — its value comes from the caller, as a parameter of the process function, so binderName x already names it — but its declared bound becomes an assertion. Every other variable emits a Go local: Pick(S) for an initializer, the compiled expression for =, and nothing for an uninitialized one, which leaves it at Go's zero value (the runtime types are built to accept theirs — tlaplus.Int's reads as 0 rather than dereferencing a nil pointer).

                                                Interleaving rather than asserting everything up front is what lets a bound name an earlier local. Nothing forces the other order: a parameter is live from function entry, a local is computed from parameters and earlier locals, and an assertion computes nothing, so declaration order is both what PlusCal's sequential initializers already mean and the order that fires each assertion as early as it can — before an initializer that could panic on the very value the assertion is there to reject.

                                                Locals come before locks because a lock is where the variable lives. A process-local exists only inside its lock's struct, so an initializer naming an earlier sibling would otherwise compile to a Go identifier that does not exist; with the locals emitted first, each lock's struct is built by naming them.

                                                Equations
                                                • One or more equations did not get rendered due to their size.
                                                Instances For

                                                  A whole process: its threads' functions, and the function that starts them.

                                                  The process function returns done immediately rather than blocking, so its caller decides when to wait. Each code thread signals the buffered done' when it reaches goto Done; a final goroutine reads done' once per code thread and only then signals the unbuffered done. Receiving threads never signal: they run until the medium vanishes, which is not the process finishing.

                                                  mailbox is a parameter, not something the generated code constructs. The compiler emits no main and takes no position on how processes find each other — whoever assembles the system supplies a Receiver backed by a socket, a queue, or a Go channel.

                                                  Equations
                                                  • One or more equations did not get rendered due to their size.
                                                  Instances For

                                                    Every channel and FIFO of the whole specification, algorithm-level and process-local alike, as (name, element type, index-domain expressions). Both the Network struct below and ProcEnv's own channel table are built from this one list — the struct is algorithm-wide, so a process may name a channel another process declared.

                                                    Equations
                                                    • One or more equations did not get rendered due to their size.
                                                    Instances For

                                                      The Network struct type: one field per channel of the whole specification, holding the sending end only — a process reads from its own mailbox, which it is handed directly, and never from the network at large.

                                                      A channel declared with an index domain (pong[Pongs]) becomes a map[Address]Sender[τ], which is what makes net.c[e].Send(…) resolve; one declared without becomes a plain Sender[τ].

                                                      Equations
                                                      • One or more equations did not get rendered due to their size.
                                                      Instances For

                                                        A whole algorithm: the Network type, then every process.

                                                        Order is for readability only — Go resolves package-level declarations regardless of the order they appear in.

                                                        Sits outside namespace Network2Go so that algo.toGo resolves by dot notation, matching Guarded2Network/PlusCal.lean's guarded.toNetworkDriver/Pipeline.lean calls each pass that way.

                                                        condBackend selects the experimental -Xgo-cond scheme for every process of the algorithm — an algorithm compiles as a whole under one backend or the other, never a mix, since it is one .go file and every process there shares the runtime import list Network2Go.Emit computes from it.

                                                        Equations
                                                        • One or more equations did not get rendered due to their size.
                                                        Instances For