goer Download ↓

Architecture / performance

Where the time goes.

Before you bet a service on something new, you want to know what it costs. Fair. So this page is the bill. Goer gives up some raw speed to get isolation and easy recovery, and we’d rather show you how much than have you find out later. You’ll see what one message costs, what a million processes (Goer’s small private workers) cost, and what the compiler does and doesn’t speed up. Every number here was measured on one busy laptop, so treat them as ballpark figures.

What a message costs

Sending a message takes a short, fixed route. You build a msg.Spec. Goer finds the receiver in a table without taking a lock. The message goes in the mailbox, and the scheduler gets to it.

Spec→find the process→mailbox→behaviour
A send that allocates nothingBuild the message with msg.NewSpec and Add, send it with System.TellSpec, and Goer reuses the memory afterwards. That measures 298 ns with no allocations. Plain Tell is 329 ns (it used to be 633).
Simple messages are one allocationA message made only of numbers, bools, and strings is packed into a single block in the receiver’s memory, and freed as a single block. A message with a nested struct takes the slower, general route.
No lock to deliverThe table of processes is published in a way that readers never have to lock. The system-wide lock is only touched in rare cases, like looking up a port.
Memory gets reusedWhen a process ends, its memory is kept handy for the next one. Goer only hands memory back to the operating system when there’s a real surplus. In steady use that means no system calls at all for memory.
chunkarena microbenchmark (8 logical CPUs, directional)
RegionCreateRelease            ~280 ns  →  ~330 ns
RegionCreateReleaseMadvise    ~2440 ns →  ~370 ns
RegionCreateReleaseParallel   ~480 ns  →  ~460 ns

Two kinds of process

The slim kind

These only understand a small fixed set of messages (runtime.CompactMsg) and are started with System.SpawnCompact. They’re laid out so Go’s garbage collector has nothing to look at, and that’s what keeps pauses short when there are a million of them. The benchmark program cmd/megapark uses these.

The regular kind

These run imgo code and take any message. Each costs Go about 827 bytes and 1.5 objects. With a lot of them the collector has more to walk through, so pauses are longer.

Both kinds share one scheduler. A fixed team of GOMAXPROCS workers lets each process handle up to 16 messages before moving on. A process with nothing to do costs nothing to run. For comparison, giving every process its own goroutine showed a pause of around 131 ms at just 120,000 of them.

A million of them

cmd/megapark is the program we measure with. It starts 20,000 processes unless told otherwise, wants an explicit -n to go above 100,000, and won’t run if it thinks it’ll need more than 2 GiB. It’s an experiment, and too heavy for a test suite.

Read these with care.The laptop was doing other things, so results drift from one run to the next. We compare runs made back to back and report the middle one. Don’t read too much into any single number.
What we ranWhat we measured
1M regular processes, busy laptop1.01M → 1.04M messages a second. Objects for the collector to track: 19.6M → 9.9M. Forced-GC pause: 432 ms on average (1.52 s at worst) → 220 ms (712 ms).
1M regular processes, quiet laptop2.23M → 2.24M messages a second. Pause: 190 ms (252 ms at worst) → 109 ms (144 ms). Same speed, half the garbage, a 40–50% shorter pause.
1M slim processes404 MiB of heap. GC pause 3.4 ms on average (4.1 ms at worst). 2.98M messages a second. 33,000 heap objects.
1M slim processes, after trimming the collector’s workObjects to track: 23.2M → 30,700. Pause: 9.05 ms on average, 35.8 ms at worst → 3.69 ms, 5.3 ms.
Killing 200,000 processes0.89 s for the regular kind, 0.36 s for the slim kind.

What’s left of the pause for the slim kind (about 3.7 ms at a million) is Go’s collector walking roughly 352 MB of memory we’re holding. Getting that lower is a job for the Go runtime. And one thing we learned: with a million mostly idle processes, the slow part of delivery is waiting on memory, so cutting allocations in half shortened the pause far more than it sped anything up.

The compiler

Goer runs process code in an interpreter. When a function has been called 1,000 times, Goer compiles it to real machine code and uses that from then on. The interpreter stays as the reference: the two are tested to give exactly the same results.

The machine code is kept very simple on purpose. It never calls anything. When it needs something done that it can’t do itself, like sending a message, it stops, hands control back to Go, and carries on afterwards. Errors work the same way: the machine code reports the problem, and Go raises the same panic the interpreter would have.

Why so careful? Because Go’s runtime expects to understand every piece of code that’s running. Keeping the generated code this plain means Go’s garbage collector and scheduler never trip over it.

What gets compiled

Nearly everything: arithmetic, branches, struct and map work, strings, type switches, sending and spawning, port calls, and even Ask, select, and defer. There’s one exception, and it’s deliberate. Functions that loop with range-over-func stay interpreted. We tried compiling them and it came out slightly slower, so we took it back out.

Tested both waysThe compiler’s own test set is 61 functions, and all 61 compile. On top of that, every program in the language tests runs interpreted and compiled, and the results have to match exactly, including how much memory was freed.
If something can’t be compiledIt just keeps running in the interpreter. Compiled.JITReport will tell you why. It costs nothing extra.
PlatformCompiled to machine code?
linux/amd64Yes. This is the main one.
linux/arm64Yes, with the same coverage. The whole test set passes under an emulator.
windows/amd64Yes, but we’ve only checked that it builds. We haven’t run it on Windows ourselves.
Everything elseNo. Everything runs interpreted. That covers macOS, the BSDs, WebAssembly, and other CPU types.

How much faster

What we timedInterpretedCompiledFaster by
VMCall/Wide (a short arithmetic loop)623.8 ns141.2 ns4.4×
VMCall/Narrow (add one; the cheapest possible call)126.3 ns80.2 ns1.6×
JITPairFieldLoop/FieldReadLoop (read three fields, 1,000 times)39.6 µs5.1 µs7.7×
JITPairFieldLoop/NestedReadLoop (read a nested field, 1,000 times)46.4 µs6.5 µs7.2×
JITPairList/ListSet (write list items, 1,000 times)100.2 µs68.2 µs1.5×
JITPairFieldLoop/Fields (write three fields, 1,000 times)161.3 µs125.8 µs1.3×
So should you care?If your process does real work on its data, like arithmetic or scanning through records, the compiler makes it about 4 to 8 times faster. If your process mostly passes messages along, expect about 1.1 to 1.2 times. In one run with 100,000 processes we got 3.04–3.10 million messages a second compiled and 2.67–2.78 million interpreted. The mailbox is the slow part there. One more honest number: a loop that panics and recovers on every pass is much slower compiled, so don’t use panics for ordinary control flow.