Go Concurrency Distilled
antonz.org357 points by chmaynard a day ago
357 points by chmaynard a day ago
The concurrency and threading in Go just feels like magic compared to every other language. I'm a goroutine addict and I refuse to be rehabilitated.
Just from observations over the years, I don't think there's any other language quite like this, in terms of how things can end up happening in any thread.
How about Erlang or Elixir using BEAM?
Supposedly WhatsApp scaled to serving over 1 billion users with Erlang and BEAM.
RabbitMQ, used by Reddit, uses Erlang and BEAM.
Discord uses Elixer and BEAM.
I just traveled down the BEAM rabbit hole. Fascinating story. The Ericsson Computer Science Laboratory cranked out some amazing products in the early 1990's.
Their goal was five nines of reliability for Ericsson telephone switches.
According to Joe Armstrong (an interesting fellow from Ericsson), the AXD301 ATM switch achieved nine nines over a nine-month period using Erlang and BEAM in 2002. That calculates out to 24 milliseconds of downtime.
BEAM+OTP is a masterclass in using concurrency to achieve fault tolerance. But Go achieves its "magic" by feeling like the lingua francas of programming, C and C++. Go doesn't make the developer learn too many new concepts. The runtime is self enclosed in the final binary. This commitment to the familiar programming patterns also means it allows for concurrency anti-patterns like shared memory which for Erlang+OTP's design principles is verboten.
Slightly irrelevant but that's a part of my issue with Go. I personally feel the chances are slim that "familiar programming concepts" (i.e. as taught by most intro CS courses) are optimal by themselves. And I know it's an old thing, but the fact that Go was once adamantly against generics...
The Go team as a whole was not adamantly against generics though, as I recall. Rather, they were against implementations that would have bad overall implications for the language (especially its complexity, both in usage and in implementation).
Once a sufficiently good proposal was made, generics were adopted.
It was considerably more messy than that but I don't want to dredge up old drama in detail.
I agree with everything you're saying. I think it comes down to design philosophy. Erlang's ecosystem is well tuned for building fault tolerant systems and features concurrency heavily to solve for that. Go is for general purpose programming in big organizations with massive variance in developer experience that has concurrency as a first class concept for the ability to scale (among other things).
Of course, in some sense fault tolerance and scaling are two sides of the same coin. They're both measures of availability. They're just different approaches to that.
What Go achieves that Erlang doesn't is the ability to "pick up and play". What Erlang achieves that Go doesn't is a pathological commitment to the system whole never going down.
The irony is that it ended up more complicated than it neeeded to be because it was added on later and had to be backwards compatible. I think if go had had generics from the beginning it could have had a simpler design. And go wouldn't have needed magic functions like make and len that are kind of generic, but not in the same way as user-defined generic functions.
> I personally feel the chances are slim that "familiar programming concepts" (i.e. as taught by most intro CS courses) are optimal by themselves.
On the other hand, Erlang has been out for ages and has largely failed to attract much adoption, so it doesn’t seem like the market finds it to be “optimal” either. Not that popularity is everything, but over time a language better languages should increase their market share, especially if your language got its start during an era where the competition was C and C++ and Java.
> And I know it's an old thing, but the fact that Go was once adamantly against generics...
Erlang not only lacks generics, but it lacks any static type system at all…
Rather than entire languages, I'd say that feature adoption is more likely the better indicator. Like generics, first-class functions, lambdas, error-handling (I'm partial to monads like `Result`), etc.
If one wanted, they could put ecosystem tooling here as well (e.g. `gofmt` saving everyone time and mental health).
Also, to clarify, I'm not arguing that Erlang > Go, I don't (purposefully) use either, though I have to read Go sometimes.
Erlang is slow for compute.
And most software does apparently not need what it offers.
I mean, Java does it even more elegantly imo and if anything it is the most stereotypical oop programming language of all time.
Can you explain how you think Java does it more elegantly? That's a very surprising statement. Java didn't even do m:n threading until "Project Loom".
Java has structured concurrency.
This poster summarized it well: https://news.ycombinator.com/item?id=49864334
Not to mention its a lot easier to learn Go concurrency over Elixirs whole ecosystem. Also Go has a job market while Elixir job market exclusively consists out of senior level job postings that get handed over from Elixir job hopper to another Elixir job hopper. There is barely any reason to learn Elixir except for being fascinated by it.
Comparing learning one language's concurrency model with learning another language's entire ecosystem is incommensurable.
Having learned both Go and Elixir, I found Elixir easier to learn and a lot more enjoyable to work with. I'm not alone in this opinion. According to Stack Overflow's 2025 "admired" languages, Elixir scored 65.9% compared with Go's 56.5%; Phoenix was the most admired web framework of 2025 at 79% and has held that spot for the past three years.
Erlang/Elxir would be more popular if it compiled to native. BEAM VM is not that performant and programs also take more memory compared to Go.
Erlang is dynamically typed, so many of the performance costs are similar to a JS runtime and always fully deciding typing ahead of time is an undecidable problem.
In practice, this is why many such languages have JIT's (unless targeting a subset or an type-information enhanced superset like TS), there was a seminal OOPSLA paper in 1995 by Agesen and Hölsze (who worked on the JVM Hotspot compiler) that compared JIT's to AOT compilation in practice (the Agesen CPA algorithm isn't perfect but it's pretty good for the time and others have probed that it's an undecidable problem).
That said, they also had a historically bad performance story due to misjudgments in development direction, the interpreter was default and they twice tried to make "HPC JIT's", ie.. complex JIT's that tried to be "perfect" and focused on numerical code gains, they'd be good for optimizing a matrix kernel, yet fairly useless or even negative on more common code patterns.
OTP 24,25 and 26 took learnings from the JS runtimes and also added compiler hints (since they already had binary precompiled modules).
What still saves the OTP runtime is that many basic operations that would suck without a good performance story is handled by built-in functions, so like Python most practical programs works well enough even if the runtime is behind.
Well to use Elixirs concurrency model you kinda have to use the rest of the langs ecosystem and learn a shit ton more compared to familiar feeling langs like Go. For Go I dont have to learn its execution model, some VM specifics and whatnot.
I looked at BEAM about a year or so ago, similar conversation here. I don't think BEAM is the same when you start looking at what part of code is executing in which thread. There's tradeoffs depending on what you're solving for, like Go makes it really simple to distribute your work across threads concurrently, but when you start looking at integrating with stuff, you run into having to do tricks to do things with unshare (ref: docker/podman/containers...) and you haven't been able to integrate into libnss since they started using some "unused linux signal" for concurrency controls (PAM used that signal).
Their concurrency models are very similar. By default there is a thread per core and the scheduler can move a process to another thread at any time. All I/O is async. Like Go, when code calls into foreign native code (NIF / cgo) the scheduler puts it on its own OS thread.
One advantage BEAM had for a long time is preemption is built into the VM and based on reductions. Go didn't have true preemption until 1.14 (before that it could only preempt at function boundaries) and its a very complicated implementation based on async signals sent from a runtime thread.
Since you’re talking about threads in the context of the BEAM, you might want to give it a deeper look. There are no threads there, at least not OS threads on the developer’s disposal.
I don't have a use case where anything in my toolbox isn't already sufficient enough to solve, it wouldn't be worth while. Maybe if I cared to work at some big place or specifically Ericsson, but there's better things to be doing with my time. There's plenty of problems that can be solved with tools like uv/Python/PyWebView.
How the OTP handles Erlang/Elixir parallelism is kind of interesting. =3
https://blog.stenmans.org/theBeamBook/#_concurrency_parallel...
Yes! BEAM and OTP is amazing. Concurrency is one aspect and Go has great concurrency primitives, but what about supervision, and recovery and failure modes? Often they’re left to the developer as per Go’s philosophy which I think makes sense. OTP offers a lot of solutions to this.
I think Go and the BEAM family languages are both great.
Yes. Once you know Erlang/Elixir and BEAM you realize it is at least a local optimum in languages/VMs.
Truly something else if error handling is built into the language as a default case, not an … exception.
If you've written a few GenServers, I'm not sure one would describe it as "magic" in quite the same way. I wouldn't anyway.
> the AXD301 ATM switch achieved nine nines over a nine-month period using Erlang and BEAM in 2002. That calculates out to 24 milliseconds of downtime.
This is misleading. I had an old Dell computer in my garage hosting a php app that hit that level of uptime as well over a 9 month period. It was 100% so actually better.
Those uptime numbers only hold water when spread over many thousands to millions of users where you’re at large enough scale that you’re actually dealing with a meaningful volume of hardware failures.
Did you come from reddit? The switch in question is a backbone switch and those at the time served tens of millions of users.
How many do you serve?
Edit: either you are a troll or you have an affinity for hateposting on HN. Nothing positive have come from your comments.
I was amazed how well are goroutines integrated into the language when I saw the first videos from Rob Pike. Then I actually started using Go for concurrent code, and noticed one thing, it's extremely easy to leak goroutines. There is no proper way to cancel them, they need to cooperate via select/context. Go developers eventually learn hacks to deal with it, but the simple go+chan style of programming style you see in tutorials is usually not safe. I still consider Go a remarkable piece of software. The runtime really doesn't have any seriously bad edge cases, it just works. But as a developer, I now prefer a slightly more explicit approach to concurrency. I've spent the last year developing an async runtime for Zig and I'm now more comfortable writing concurrent code in Zig than I was every using Go. I have more options for how to handle closed channels, I can cancel any operation, etc.
One reason it's easy to leak goroutine is that channel producer blocks waiting on consumer, once channel consumer exits the producer goroutine leaks. Go doesn't allow consumer to close the channel.
In Rust when receivers all drop, the producer will error instead of blocking, so Rust is better in this aspect.
> There is no proper way to cancel them, they need to cooperate via select/context
Isn’t this also true of threads? I know you can usually cancel them from a thread handle, but that kills the thread ~immediately without cleaning anything up, right? Presumably you pretty much always want cooperative cancellation?
It's true for almost all pthread implementations, not all. But when talking about asynchronous I/O runtimes and coroutines, you have more options. Systems like Tokio, or zio (the one I'm working on), give you a task handle, and when you call `cancel()` on the handle, it will cancel whatever async operation the task is currently running. And it does so reliably.
These things were all known when development on Go began. But, as with so many other aspects of the language, if it wasn't known in the 80s/90s then it may as well not have existed.
Yup. A popular way to get proper cancellation is to build exceptions into the language and specifically async exceptions so one goroutine can throw an exception into another goroutine. And Go does not have exceptions. Doing so would require all regular Go code to be exception safe, and really requires some form of try/finally or RAII but not defer. Anyways exceptions are quite far from the Go creators’ vision of the language.
I agree when it comes to multicore machines. But going further to perform parallel computing across processors with no shared memory is not well supported in naive Go.
https://bil-lang.org aims to address this gap … I wrote a post about Bil’s adjustments to Go here https://bil-lang.org/blog/rethinking-classical-concurrency-p...
We're heavy user of Go at work. Go also makes it way to easy to write bad concurrent code and hard to write good one.
Stick to err/wait group and go routines and it's OK. Any PR with a channel or mutex I'll assume the author made a mistake.
Asking as an outsider to Go. How do you communicate between threads without a channel or mutex?
A goroutins shares memory so you can just share variables if you want. It is basically the same as any other language in this regard.
It's also just as easy to incorrectly share memory and cause race conditions. There's no protection against it and it violates memory safety.
For sure. The parent comment was merely asking if it could be done without channels or mutex, not if it was a good idea or not. Of course sometimes it is indeed the right thing to do.
>> in terms of how things can end up happening in any thread
Doesn't that describe pretty much any green thread style concurrency implementation.
No. Preemptive scheduling plus M:N mapping combination that Go has is not common in other major implementations.
Other languages and their implementations of green threads usually have cooperative scheduling or M:1 mapping
C# and Rust (via Tokio) both have M:N threading. They both use a work-stealing algorithm to map many tasks onto a finite thread pool. But you're correct that they are cooperative via async/await, not pre-emptive.
I feel like you can't describe something as m:n when it the m uses async/await and doesn't actually have a green thread?
Define the "green thread"? By every definition I've seen[1], the task objects I mention are green threads.
Very curious how much Go wins by this. How worse would typical Go programs run with pre-emption disabled?
You can try this yourself: GODEBUG=asyncpreemptoff=1
Also platforms like Wasm still do Mx1 scheduling without async preemption, where Gosched is required at places.
E.g.: my "transpiled" SQLite driver takes special care to make sure long running SQL queries (and the busy handler) can be canceled with contexts even on platforms without async preemption.
I believe it’s more about robustness than performance in typical cases. Without pre-emption, there’s always a risk of one goroutine using disproportionate CPU time if it gets into an infinite (or just very long) loop without doing any IO.
I'm curious; using hardware threads is M logical threads preemptively scheduled on N physical cores. In what way does this not satisfy the original criteria?
They are still full OS threads: they have a full-size stack and have all the same overheads for context switching. Why would you think that a marketing term for a CPU feature is equivalent to an M:N scheduler?
A "hyperthread" can schedule work for two OS threads simultaneously on a single core. An M:N scheduler will schedule millions of green threads on as many cores/hardware threads as you give it (typically you'd give it all of them).
That's just not what is referred to as green threads.
Yes, in implementation they are not. I'm curious what the difference in subjective experience is.
Green threads don’t allocate stack frames and do not require such a massive context switch, which in turn allows for many more threads.
But if you happen to call a blocking syscall everything breaks.
That is not true of preemptive green threading systems. Go/Erlang/Haskell all run native foreign code on its own threads, and manage all internal I/O with async schedulers that never block. They also preempt user code in tight loops.
Those are the only mature languages that have all of these features.