Skip to content
Frozen Handshake

Frozen handshakes

Every concurrency primitive is a handshake between two roles. The writer and the reader. The producer and the consumer. The owner and the borrower. The primitive’s job is to coordinate that handshake. Its design is the set of decisions about how each end behaves.

A primitive is frozen when those decisions are baked into the type, fixed at construction, and the same on every call. The median program wants exactly that. A pathological program does not.

What a freeze looks like

std::sync::Mutex<T> is the easy example. Its lock() is spin-then-park: a few hundred cycles of CAS attempts, then a syscall to WaitOnAddress (Windows) or futex (Linux). That strategy is a compile-time choice. There is no Mutex::set_strategy. You cannot, halfway through your workload, decide that your lock should never park because contention is rare and the syscall costs more than the spin saves. The decision was made when std::sync::Mutex<T> was designed.

Arc<T> freezes a refcount layout. Two usize counters on the heap, a control block per value, atomic RMW on every clone and drop. That is correct for most uses. It is wrong when every clone is contested by sixteen threads and the refcount line ping-pongs. But Arc<T> has no escape; you get the layout or you do not use the type.

HashMap<K, V, S> freezes a hasher into the type parameter S. The default RandomState re-seeds per process for DoS resistance. That choice is correct if you serve untrusted requests. It is wrong if you want two processes to share a hash map and agree on key locations - because RandomState’s seed differs per process, the same key hashes to different slots in the two maps. So std gives you no shared-hash-map type. You compose your own with a deterministic hasher, and the layout question becomes yours to answer.

std::sync::mpsc::channel freezes single-process-in-memory. There is no mpsc::open that takes a path. The MPSC discipline itself (one receiver) is part of the freeze. To go cross-process you reach for sockets or pipes, both of which freeze a different set of decisions and pay a syscall on every send.

The pattern: each std primitive picks one point on a multi-dimensional design space and freezes the rest. The point is the median-correct one. Workloads that need a different point have no recourse short of replacing the type.

Why freeze at all

The freeze is not laziness. It is what makes the primitive cheap on the median.

Mutex::lock is fast on the uncontended path precisely because it does not have to dispatch through a strategy lookup. The compiler sees the concrete park primitive and inlines it. A mutex type with a strategy field pays a load and a branch on every lock() even when nobody contests it.

Arc::clone is one atomic increment because the refcount layout is known. Make the layout a runtime choice and every clone has to load a layout descriptor and dispatch on it. The single-threaded program that just wants three handles to the same Vec pays nothing for the contention case it does not have.

So the freeze is the deliberate exchange. Hot-path cost goes down, adaptability goes to zero. For the median user, that exchange is right.

Where it stops working

Two tails make it wrong.

Topology tail. Your data does not stay in one address space. A cache eviction agent reports to a metrics scraper running in a different process. A long-running daemon hands off state to its successor across a restart. A producer wants to feed a consumer that may not even be running yet. The primitive’s freeze on “both ends share the same address space” is what cuts you off.

Workload tail. Your op stream is not the one the freeze was designed for. The mutex is uncontended for the first ten minutes then hammered by twenty threads. The cell starts read-heavy and turns write-heavy after lunch. The hash map’s key distribution inverts when traffic shifts from one country to another. The primitive’s freeze on “one strategy fits the whole workload” is what costs you.

These two tails are independent. The MMF cross-process case may also have a uniform workload. The contended in-process case may fit entirely in one address space. They do not imply each other, they do not compose into one solution. Each demands its own un-freezing.

Two axes, orthogonal

SubEtha picks both axes.

Topology, un-frozen. subetha-cxc’s SharedRing / SharedHashMap / SharedRWLock / and the rest lift the primitive’s state into a memory-mapped file. The same byte layout serves cross-thread (two threads in one process map the same file), cross-process (two processes open the same file and the kernel page-aliases them onto identical physical pages), and disk-persistent (the file survives a process restart and the next consumer reopens where the previous left off). One byte layout, three deployment modes, no recompile in between. See the MMF substrate explanation for why one layout is enough.

Workload, un-frozen. subetha-cxc’s AdaptiveIpc<T> and the AutoIpc builder carry a strategy tag in the handshake header. The sidecar watches op-stream samples and swaps the tag when the workload shape shifts: SharedRing to SharedDequeKhl when a work-stealing pattern emerges, SPSC to MPMC when a second producer thread appears, single-mailbox to SharedDequeUrd when per-thief mailboxes match the consumer set. The migration is non-blocking. Readers in flight on the old strategy continue to completion; the next readers come in on the new strategy. The migration module in subetha-core ships the MigrationGuard RAII protocol that brackets the swap.

The two un-freezings share the same substrate (subetha-core) and the same control plane (subetha-sidecar). So a primitive that wants both - say, an AdaptiveIpc<u64> whose underlying variant switches based on observed traffic shape - gets both axes from the same handshake / observation / policy machinery.

What the freeze still buys you

SubEtha does not abolish the freeze. It moves the freeze to finer granularity.

On the hot path, the strategy tag is one Relaxed load and a branch. Roughly 300 picoseconds on Zen+. The branch target is the concrete strategy implementation; for T = u64 the TypeId-monomorphised branch in AdaptiveIpc::send resolves the specialisation at codegen time, so LTO inlines the body directly at the call site and the indirect-dispatch cost goes to zero. Only the inline tag-check branch remains.

The handshake bracket - enter_op plus exit_op plus an optional observation push - fires only on flagged ops. Those ops are the ones the sidecar wants to know about: lock attempts that parked, CAS attempts that lost, snapshot reads that retried. A Mutex::lock on the uncontended path pays only the bracket load + branch; the observation push is gated on the slow-path flag, so uncontended ops do not push anything.

So the median program pays roughly the same as the frozen equivalent. The tail program pays a small bracket cost on the ops the sidecar needs to see, and in exchange gets to migrate strategies under live traffic. The exchange is now per-op, not per-program. That is the part the freeze locked away.