← all diagrams · when one side fails · React · Temporal · ECS

Async and memory-safe: why GenServers suit short-lived agents

mycelia's agents come and go all day: one wakes, streams for an hour, sleeps after 30 idle minutes, gets restarted after a crash. On the BEAM, each one is a set of tiny processes with their own memory, that only talk by sending messages. This page shows what that buys, next to the usual alternative: one server process whose agents all share one memory and one event loop.

Try it: five agents, two runtimes

Both sides run the same five agents. The counters show each agent getting work done. Pick something to happen to spore 3 (◀) and watch the other four.

BEAM: a process per agent

each with its own heap, garbage collected on its own

One shared process

a typical Node or Python server: one heap, one event loop
shared heap
Pick something to happen to spore 3.

A simplified model to show the shape of each runtime, not a benchmark. A carefully written single-process server can catch many of these (try/except around each task, worker threads, memory caps); the difference is that the BEAM gives you the isolation by default, for every process.

"Async": every process waits on its own

On the BEAM

  • Sending a message never blocks. It lands in the other process's mailbox; the sender carries on. GenServer.cast is exactly that.
  • GenServer.call waits, but only the caller waits, with a timeout (5 s by default). Nothing else in the system is held up.
  • No async/await. Code is written straight down, and can simply wait (receive, a slow HTTP read). The VM parks that one process and runs others.
  • Preemptive scheduling. Each process gets a small budget of work (about 4,000 "reductions"), then the scheduler switches. A process stuck in a loop slows itself, not its neighbours.

In one event loop

  • Concurrency is cooperative: tasks take turns only at an await.
  • A task that computes for 3 seconds without awaiting stops every other task for 3 seconds.
  • Every function that might wait has to be async, and so does everything that calls it.
  • Fine for I/O-heavy work, if every library behaves.

In mycelia: each spore's tree has a Task that reads the pod's event stream, separate from the Agent GenServer. The reader can sit on a slow stream for an hour while the Agent stays free to answer the page. Six spores streaming at once are six processes waiting, which costs almost nothing.

"Memory-safe": nothing is shared, so nothing can be corrupted

On the BEAM

  • Each process has its own heap. A process starts with a few hundred words of memory and grows as it needs.
  • Data is immutable, and messages are copied into the receiver's heap. No other process can change your data under you: no locks, no data races.
  • Garbage collection is per process. Collecting one agent's heap pauses only that agent, briefly; there's no stop-the-world pause for everyone.
  • When a process dies, all its memory is freed at once. No reference from elsewhere can keep it alive, because nothing else could point into it.
  • A per-process cap is possible: max_heap_size kills a process that grows too big, and its supervisor restarts it, before it hurts anyone else.

With one shared heap

  • All agents' objects live together; any code can hold a reference to any of them.
  • One agent's huge allocation means a bigger collection, which pauses all of them.
  • A finished agent's data is only freed if nothing still points to it: a forgotten cache entry or listener is a leak that grows with every agent that comes and goes.
  • If the process runs out of memory, every agent in it goes down together.
What "memory-safe" doesn't promise. The BEAM can still run out of memory: a mailbox has no size limit, so a process sent messages faster than it reads them keeps growing; large binaries (over 64 bytes) live in a shared, reference-counted area; ETS tables and atoms are shared too. Safety here means no shared mutable state and no corruption between processes, not "can't leak". And it's not memory safety in Rust's sense: the VM itself is written in C. Whether mycelia sets max_heap_size isn't something these diagrams say.

Why this fits ephemeral agents

In an agent's lifeWhat the BEAM doesWhy it matters
it wakesstarts a tree of processes in microseconds, with a few KB eachWaking is cheap, so sleeping after 30 idle minutes costs nothing to undo. Up to 16 spores' trees share one mycelia pod.
it streams for an hourthe reader process waits on the stream; the scheduler runs everyone elseLong, slow, mostly idle work is the normal case, not a problem to engineer around.
it produces a huge outputonly its own heap grows and is collectedOne chatty agent doesn't make the page stutter for everyone else.
it hits a bugits process dies; its supervisor restarts it in a clean stateA crash is a restart of one agent, not an outage. See when one side fails.
it goes to sleepits processes exit; all their memory is freed instantlyThousands of wake/sleep cycles leave nothing behind. A server that runs for months doesn't slowly fill up with dead agents' data.

The same idea, one level down

The BEAM isolates each agent's processes inside mycelia. Kubernetes does the same for the heavy part, each agent's pod: its own memory (3 GiB, enforced), its own CPU (2), its own files. When a pod runs out of memory, Kubernetes kills that pod alone, and the Host starts a new one. So an agent is isolated twice: its small bookkeeping on the BEAM, and its real work in a pod.