Skip to content

Rationale · essay 04

Concurrency

rationale.md · 83 lines · 4 min read

The redefinition: shared-nothing, with isolated actors talking through typed channels, under supervision. The same Channel[T] serves local and network.

Mainstream concurrency is threads + shared memory + locks. You create threads, they share the heap, and mutexes/atomics protect what is shared. It works, but the bug category “I forgot to synchronize” is endemic, and the model does not cross the network (a mutex does not work between machines).

The inheritance is the BEAM/Erlang one: the unit is the process, isolated, with its own heap, cheap. Processes do not share memory (there is no @shared). They talk through a typed channel, Channel[<-T, ->U], where the notation is the perspective of whoever holds the handle: <-T = “I send T”, ->T = “I receive T” (it eliminates the type inversion in the other side’s signature). Operators: chan <- data (send), data := -> chan (receive), and <-> (synchronous request-reply).

And the turn that unifies two worlds: the network channel is the same Channel[T] as the local one. There is no “local channel” vs “remote RPC”; there is one type, two contexts. The physics of the network only expresses itself through two requirements that already exist in the language: the payload has to become bytes (T + Serializable) and establishing can fail (Result + timeout). That is native typed RPC: both sides share T at compile time, without an IDL or a generated stub.

Why shared-nothing? Because it is what makes the rest of the design possible. Without concurrent aliasing, the borrow checker becomes unnecessary (ch. 02); memory can be colorless; a panic in one process does not corrupt another’s heap. The isolation is not an imposed restriction; it is the foundation from which memory safety without lifetimes and fault tolerance sprout.

Why does <-> require a timeout? Because two processes doing <-> to each other would give guaranteed deadlock, and a silent deadlock is the worst possible failure: the system freezes waiting for a response that never comes, with no warning. The mandatory deadline is the structural way out: it makes the timing visible and forces the honest question “what if it does not respond?”. (The asynchronous <- does not need it, because it does not wait.)

Why does supervision emerge from the structure? The BEAM has link/monitor/trap_exit as raw primitives. Makoto does not expose them: a process’s death is an ordinary error (the defers run, the @mm is freed, the reason is produced), and observing the death without dying along with it is literally what the catch |e| of a supervised spawn already does; a monitor is not a new construct, it is the name of that. Coupling fates (links) is the tree: processes in the same spawn-block live and die together because the structure says so. The three strategies (one_for_one, one_for_all, rest_for_one) fall out of the shape of the code, not of a separately declared behaviour.

  • Threads + mutex + shared memory (Rust/C++/Java). It reintroduces the concurrent aliasing that would force the borrow checker, spreads the “I forgot the lock” category, and does not scale to distributed (a mutex does not cross the network). Shared-nothing eliminates the entire bug category.
  • The pure Go model (goroutines + sync). Go has channels, but it also allows shared memory and locks, and the aliasing door stays open. Makoto closes it: only channels, zero shared memory.
  • Raw link()/monitor() (Erlang). Death cascade through loose links is non-local: “who killed whom?” stays implicit. The supervision tree makes the coupled fate visible in the structure.
  • A separate channel type for the network / an RPC framework. It would reopen the “local vs remote” asymmetry. Reusing Channel[T] with T + Serializable lets the physics (serialization, failure) express itself through the constraint and through Result, without a second communication model.

The data race that only appears in production under load, because someone forgot a lock. The system that freezes waiting for a network response that never arrives, with no timeout, no log. The death cascade in Erlang where a forgotten link() brings down half the system and nobody knows the path. Each one is a failure that isolation + mandatory-timeout + supervision-tree make impossible or visible.

Isolated processes talking through channels, supervised. Death is a value that rises to the supervisor; it restarts, brings down the group, or escalates, all emerging from the code’s tree.

Shared-nothing costs copy/message-passing between processes: you pay in throughput what you gain in correctness and in native-distribution. The supervision tree is more rigid than raw link(): you only monitor the children that you spawned; cross-tree observation goes through a normal channel. Less flexible than Erlang, more structured on purpose.

Next: 05 · Errors