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.
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).RegionCreateRelease ~280 ns → ~330 ns
RegionCreateReleaseMadvise ~2440 ns → ~370 ns
RegionCreateReleaseParallel ~480 ns → ~460 nsTwo kinds of process
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.
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.
| What we ran | What we measured |
|---|---|
| 1M regular processes, busy laptop | 1.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 laptop | 2.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 processes | 404 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 work | Objects 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 processes | 0.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.
Compiled.JITReport will tell you why. It costs nothing extra.| Platform | Compiled to machine code? |
|---|---|
| linux/amd64 | Yes. This is the main one. |
| linux/arm64 | Yes, with the same coverage. The whole test set passes under an emulator. |
| windows/amd64 | Yes, but we’ve only checked that it builds. We haven’t run it on Windows ourselves. |
| Everything else | No. Everything runs interpreted. That covers macOS, the BSDs, WebAssembly, and other CPU types. |
How much faster
| What we timed | Interpreted | Compiled | Faster by |
|---|---|---|---|
VMCall/Wide (a short arithmetic loop) | 623.8 ns | 141.2 ns | 4.4× |
VMCall/Narrow (add one; the cheapest possible call) | 126.3 ns | 80.2 ns | 1.6× |
JITPairFieldLoop/FieldReadLoop (read three fields, 1,000 times) | 39.6 µs | 5.1 µs | 7.7× |
JITPairFieldLoop/NestedReadLoop (read a nested field, 1,000 times) | 46.4 µs | 6.5 µs | 7.2× |
JITPairList/ListSet (write list items, 1,000 times) | 100.2 µs | 68.2 µs | 1.5× |
JITPairFieldLoop/Fields (write three fields, 1,000 times) | 161.3 µs | 125.8 µs | 1.3× |