Go, Kotlin, and Erlang: three ways to switch tasks safely
The post, titled 'A Quick rundown on concurrency and gc' (its Hacker News submission carries a different, more offhand title, 'Not sure where I am going with this garbage collection rabbit hole'), is a 114-minute reference document by a blog author writing as ikouchiha47. It works directly from primary sources: the Go runtime, Kotlin's standard library and the separate kotlinx.coroutines library, OpenJDK/HotSpot, GraalVM's SubstrateVM, the Erlang/OTP source tree, and a print copy of The Garbage Collection Handbook, 2nd edition (2023). The framing question is simple: Go, Kotlin, and Erlang/Elixir running on the BEAM virtual machine all solve the same problem, running many logical tasks on a small number of OS threads through an M:N scheduler, but each gives a different answer to who controls the switch between tasks and what that controller needs to know to do it safely. That answer, the piece argues, decides everything downstream: whether the model is cooperative or preemptive, whether a GC pause stops one thread or the whole process, and whether a crash stays contained or takes the program down with it. The background numbers it gives for why one OS thread per task does not scale: a Linux default stack of about 8MB per thread, and 10,000 threads meaning gigabytes of stack space committed before any work happens.
Go's model traces to Tony Hoare's 1978 Communicating Sequential Processes (CSP) paper, work aimed at making concurrent programs provable by replacing shared mutable state with synchronous, named message passing between processes; CSP was later formalized into a process algebra alongside Robin Milner's independently developed CCS. Go's designers, credited in particular to Rob Pike, who had earlier worked on the CSP-influenced languages Newsqueak and Alef, took CSP's channel-based communication without adopting Hoare's full formal calculus or its synchronous-only restriction, since Go's buffered channels allow asynchronous sends. The scheduler itself is the GMP model, goroutine, worker thread ('machine'), and processor, from Dmitry Vyukov's 2012 go11sched design, built specifically so each processor can own a local run queue instead of every worker thread contending on one global queue; a goroutine blocked in a syscall releases its processor so another worker thread can pick up new work.
Through Go 1.10, preemption was cooperative: it worked by poisoning a stack-bound check at function prologues, so a goroutine whose loop body called no functions, a bare 'for {}', never hit that check and could never be preempted. That mattered specifically because Go's stop-the-world GC phases need every goroutine to reach a safe state before mark or sweep can proceed, so a spinning goroutine with no calls could hang a GC cycle indefinitely; that was the measured production problem behind a 2019 Go proposal for non-cooperative preemption. An intermediate fix, inserting checks at every loop back-edge, was measured and rejected for a 7.8% throughput regression. Go 1.14 shipped signal-based preemption instead, sending the Unix SIGURG signal to the specific OS thread running a stuck goroutine. SIGURG was chosen against four stated criteria: it had to be a signal debuggers pass through by default, not claimed internally by libc in mixed Go and C binaries, safe to receive spuriously, and available on platforms without real-time signals; it works partly because out-of-band TCP data, SIGURG's original purpose, is essentially unused in practice. The GC itself is described directly from source as a concurrent, type-accurate mark-and-sweep collector with a write barrier, non-generational and non-compacting; the mark phase tracks every object as white, grey, or black, and a hybrid write barrier, a Yuasa-style deletion half plus a Dijkstra-style insertion half, enforces a 'no white to black' invariant so a fully-scanned object can never silently gain a pointer to something the collector still thinks is garbage.
Kotlin cannot change the JVM's thread model, so its only lever is using fewer threads by not letting a suspended computation hold one. That happens at the compiler level, not in a library: a suspend function compiles into a state machine implementing the standard library's two-method Continuation interface, and the compiler only ever inserts a resumable point at an actual suspend call. A loop with no suspend calls inside it therefore has no resumable point anywhere, and nothing external, comparable to Go's SIGURG, exists to interrupt it from outside. Everything past raw suspension, dispatchers, structured cancellation, and the actual thread pool, comes from kotlinx.coroutines, a separate library published by JetBrains rather than part of the Kotlin language itself. Its CoroutineScheduler documents its own design lineage directly in its source comments, crediting Go's scheduler by name as the inspiration for its single-slot LIFO buffer, and implements the same shape: per-worker local queues with a shared global queue as overflow. A dispatcher decides which thread a suspended call resumes on by wrapping the compiler-generated continuation in a DispatchedContinuation object, whose own resumeWith method either resumes in place or reschedules the call onto the dispatcher's thread pool. Coroutines add one heap allocation per suspend point but do not change which GC algorithm runs underneath them; JVM safepoints are a separate, JIT-driven polling mechanism unrelated to coroutine suspension.
Erlang's original design question, credited to Joe Armstrong, was not about performance: it was how to build a system that can run essentially forever without going down, which the piece traces directly to isolation. BEAM processes, VM-managed rather than OS processes, share no memory: each has its own heap, stack pointer, and register state, so sending a message means copying the data into the receiving process's own heap (large binaries are the exception, reference-counted in a shared heap to avoid the copy). Because no process can see another's memory, preemption needs no signal, stack map, or poll page: the interpreter charges one 'reduction' per roughly one function or built-in call, and a process is swapped out once its budget, the CONTEXT_REDS constant in the current source, hits zero; that budget is currently 4,000, and the piece specifically flags the widely repeated figure of about 2,000 reductions, still common in older Erlang documentation and blog posts, as stale. Because every point between BEAM instructions is already safe to inspect, GC runs per process, so one process's pause is invisible to every other process running concurrently on other scheduler threads, and a crash is just one process exiting: the 'let it crash' fault model follows directly from that isolation rather than sitting on top of it as a separate design choice.
Separately from Kotlin, the piece gives its own section to how the JVM itself implements safepoints, the general mechanism, not GC-specific, also used for deoptimization, biased-lock revocation, thread dumps, and class redefinition, that freezes every Java thread in a known state. HotSpot's classic mechanism polls a memory page: the JIT compiler emits a near-free read at loop back-edges and method entry or exit, and requesting a safepoint means protecting that page so the next thread to poll it takes a hardware trap into safepoint handling. Being 'at a safepoint' is not the same as being blocked: code such as a JNI call can sit at a safepoint, its state frozen and inspectable, without being descheduled. In production a thread can still simply fail to reach a poll point in time; HotSpot's own answer, read directly from its source, is a configurable watchdog, SafepointTimeout and AbortVMOnSafepointTimeoutDelay, that will deliberately send SIGILL to crash that thread rather than let the whole JVM hang forever. The piece calls this the closest direct JVM analogue to Go's pre-1.14 failure mode: one runtime built an explicit crash failsafe for the stuck-thread case, the other redesigned its preemption mechanism so the stuck case could not occur at all.
GraalVM Native Image, built on SubstrateVM, ahead-of-time compiles JVM bytecode into a native binary and removes JIT-triggered safepoints, but it does not touch Kotlin's coroutine model, since the CPS transform happens in the Kotlin compiler before GraalVM ever sees bytecode. Its own safepoint mechanism genuinely differs from HotSpot's rather than just reimplementing it: instead of a shared, page-protected trap, each thread periodically checks its own SafepointCheckCounter, and a safepoint is armed by compare-and-swapping every thread's counter to its negative value; a thread that notices its own counter has gone negative takes the slow path and blocks on a mutex. The piece calls this architecturally closer to BEAM's reduction counting, a per-thread software counter, than to HotSpot's shared trap-triggering page, while noting SubstrateVM still has a shared heap and still needs a global stop, unlike BEAM's structural exemption from the problem. It leaves open why SubstrateVM's engineers chose that design over reusing HotSpot's poll-page approach.
The piece's own side-by-side comparison distills the pattern: the safepoint problem exists exactly where preemption is non-cooperative and memory is shared, which is Go's situation and the reason it needs stack maps and signal handling at all. BEAM has non-cooperative preemption but no shared memory, so the problem never arises. Kotlin has shared memory but no non-cooperative preemption of coroutines, so it also sidesteps the problem, at the cost that a runaway coroutine loop cannot be preempted from outside. A later section, read from The Garbage Collection Handbook's 2023 second edition, generalizes the write-barrier logic into a weak and a strong tricolor invariant: the strong version, no pointers at all from a black object to a white one, is required only for moving collectors, while Go's non-moving collector gets away with the weaker one. It gives real mechanisms behind several production collectors: G1 skips scanning newly allocated objects using a per-region 'Top At Mark Start' address threshold rather than Go's global mark-new-allocations-black approach; ZGC's 'colored pointer' is four real tag bits, for finalizable, relocated, and two marking states, packed into a 64-bit pointer's unused high bits on a system that only needs 47 address bits, healed in place by a load barrier the first time a thread touches a stale one; and Shenandoah self-heals the same way but by compare-and-swapping a forwarding pointer back into the original slot, with the Handbook noting that Shenandoah's own authors are, as of 2023, actively reconsidering the 2016 argument that led them to skip generational collection in the first place. Further back in the same section: Azul's earlier Pauseless/C4 collector, which ZGC's design followed and which uses a single stolen address bit as its load-barrier tag; Compressor, which drives compaction through the same page-protection trap idea as HotSpot's poll page; Platinum, which uses Intel memory protection keys instead of syscall-heavy page protection; the real-time Metronome collector, which schedules fixed 500-microsecond collector slices inside a 10-millisecond window to guarantee a minimum mutator utilization commonly cited at 70%; and Staccato, Chicken, Stopless, and Clover, lock-free compaction schemes built around a 'ragged synchronization' pattern in which each mutator thread crosses a safepoint at its own pace instead of all of them meeting at one global rendezvous.
The document is built for repeated study rather than one read-through: every section ends with a checkpoint question, and a closing section lays out a four-pass reading roadmap (orient on the problem, find each system's one binding constraint, map rejected paths and admitted weak spots, then compress each system to a single sentence), plus a mindmap diagram of the whole argument. It names three places worth rereading first if time is short: Go's rejected loop-back-edge fix, HotSpot's own safepoint-timeout crash failsafe, and Shenandoah's authors questioning their own founding design argument.
Key facts
- Go measured a 7.8% throughput regression from an intermediate fix (checking for preemption at every loop back-edge) and rejected it, shipping signal-based SIGURG preemption in Go 1.14 instead, chosen against four specific safety criteria for the signal.
- BEAM currently swaps a process out after 4,000 reductions, the CONTEXT_REDS constant in erl_vm.h; the piece flags the roughly 2,000 figure common in older Erlang documentation and blog posts as stale against the current source.
- HotSpot ships a production watchdog, SafepointTimeout and AbortVMOnSafepointTimeoutDelay, that will deliberately crash a thread with SIGILL if it fails to reach a safepoint in time, rather than let the JVM hang forever.
- GraalVM's SubstrateVM arms a safepoint by compare-and-swapping each thread's own counter to negative rather than protecting a shared memory page the way HotSpot does, a mechanism the piece calls architecturally closer to BEAM's reduction counting.
- ZGC's 'colored pointer' is four specific tag bits, for finalizable, relocated, and two marked states, packed into the unused high bits of a 64-bit pointer, with a self-healing load barrier that fixes a stale color in place the first time a thread reads it.
Why it matters
Concurrency and GC explainers are common; what this one does differently is derive three systems' preemption models from their own source code and connect them causally back to one question, who controls switching a running task, rather than listing each as an unrelated set of features. That lets it explain why a GC pause stops one thread in BEAM, the whole process in Go, and is not really a preemption question at all for Kotlin's coroutines, instead of just stating that each behaves differently. It also catches its own community's drift: the roughly 2,000-reduction figure still repeated for Erlang turns out to be stale next to the current 4,000, a concrete example of why reading the current source beats trusting a remembered number.
Who it affects
Backend and systems engineers writing Go, Kotlin, or Erlang and Elixir day to day; JVM performance engineers debugging GC pauses, safepoint stalls, or 'time to safepoint' latency; and anyone evaluating GraalVM Native Image who assumes its safepoint mechanism is just a recompiled copy of HotSpot's, when the piece shows it is a genuinely different, per-thread counter design.
How to use it
It is a free public blog post with no pricing or license terms attached. It is built for repeated study rather than a single pass: each section ends with a checkpoint question, and a closing section lays out a four-pass reading roadmap (orient on the problem, find each system's one binding constraint, map rejected paths and admitted weak spots, then compress each system to one sentence), plus a bibliography that separates primary sources actually read, project source trees and a print copy of The Garbage Collection Handbook, from secondary sources merely cited. A reader short on time is pointed explicitly at three places worth rereading first: Go's rejected loop-back-edge fix, HotSpot's safepoint-timeout watchdog, and Shenandoah's authors reconsidering their own 2016 design argument.
How solid is it
Nearly every technical claim is tied to a named source file, and often an approximate line or page number, quoted directly rather than paraphrased from memory, across the Go, Kotlin, kotlinx.coroutines, OpenJDK, GraalVM, and Erlang/OTP source trees plus a 2023 GC textbook. The author draws an explicit line between what was read directly and what was not: several points, why SubstrateVM's engineers chose a counter over a poll page, the exact source line where kotlinx.coroutines constructs a DispatchedContinuation, are flagged as open questions rather than filled in, and the piece corrects at least one gap from its own earlier draft, adding the JVM safepoint section and fixing a claim about where DispatchedContinuation gets built. Against that: this is one independently published blog post with no outside review yet. Its Hacker News listing carries the title 'Not sure where I am going with this garbage collection rabbit hole' and had drawn 5 points and no comments in about 86 hours; the post itself is titled 'A Quick rundown on concurrency and gc,' with a subtitle naming all five systems it covers and a structured, checkpoint-driven format, a real title that does not match the throwaway one attached to the submission.
Risks and caveats
No direct performance benchmark appears anywhere in the piece comparing goroutines, Kotlin coroutines, and BEAM processes against each other; the only measured throughput figure given for any of the three systems is Go's own 7.8% regression for a fix that was rejected, so any broader performance conclusion goes beyond what the source actually measured. Numbers that look similar are not interchangeable: BEAM's CONTEXT_REDS is 4,000 in the current source, not the roughly 2,000 many older write-ups still cite, and the piece's own caution, check the constant in the version you are targeting rather than trust a remembered figure, applies to itself a year from now too. A few specific mechanisms are described only as far as the author's own source-reading went: why GraalVM's SubstrateVM engineers chose a per-thread counter instead of HotSpot's poll-page approach is named as unconfirmed, as is the exact line where kotlinx.coroutines constructs a DispatchedContinuation.
“The original idea with a single-slot LIFO buffer comes from Golang runtime scheduler by D. Vyukov. It was proven to be 'fair enough', performant and generally well accepted and initially was a significant inspiration source for the coroutine scheduler.”
— CoroutineScheduler's own doc comment, kotlinx.coroutines source, quoted in the post