Comment by SamInTheShell
1 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...
7 replies →
I mean, Java does it even more elegantly imo and if anything it is the most stereotypical oop programming language of all time.
2 replies →
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.
4 replies →
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.
2 replies →
The elixir documentary doesn't quite have the same zest
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.
Never really thought about this but it seems to me that if you want to launch separate processes? Goroutines are built around functions, so you're just stuck with function semantics. If you want an entire process, you just invoke self with a feature flag on your binary and control a subprocess. If you need to communicate you establish your own message passing channels with STDIO or something.
I don't feel this is hacky or even a work around. Just different promises on what goroutines are vs threads/concurrency/processes in other languages.
Exit and panics are promised at the process level in Go.
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.
2 replies →
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.
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?
Wait groups do use concurency primitives underneath. My point is that the primitives that go provides are too easy to use incorrectly. And the language makes it hard to build nice abstraction on top of them. So I generally steer people away from using them unless strictly necessary, channels in particular.
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.
2 replies →
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...
>> 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.
6 replies →
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?
9 replies →
Haskell had preemptive M:N green-threading before Go was invented.
I believe Go didn't originally have it, and added it in 2020, 14 years after Haskell.
2 replies →
Java's virtual threads are M:N.
2 replies →
Kotlin's coroutines basically had the same API as Go's. But also the ability to confine some coroutines' execution into certain threads (e.g. UI main thread).
Then they added structured concurrency, which roughly solves the same problem as Go's context, arguably more elegantly.
Kotlins coroutines are super super super underrated imo. I loved working with them so much before ai. Fuck ai.
Sorry my ignorance, but what does AI have to do with you not being able to use kotlins concurrency model?
I used to feel the same way, then I started writing Rust. I got tasks (goroutines) and channels which are largely the same as Go - except I never need to worry about race conditions or nil pointers and it's nearly impossible for LLMs to generate broken code (bad code, yes, broken code no).
I have tried but I honestly can't go back to Go now, it's so much harder
This is an experience I personally had as well. It's a saving grace when working with junior developers because you know they won't end up writing parallelism-related heisenbugs.
I mean, that's one area where the story is not as nice in rust. Afaik the core abstraction is a bit leaky to be usable with tokio and other implementations as well.
Julia has a very good threading story. Task based, M:N, a lot of schedulers, structured concurrency, distributed. Sanest atomics I’ve seen. All in the stdlib.
Crystal with Fibers.
Pony with actor model.
Erlang/Elixier on BEAM VM.
I learned Haskell before that, and frankly the concurrency in Go feels similar, but is a definite downgrade due to the lack of STM.
You can implement channels and select using STM, so these don’t have to be in the standard library. And the contentious design choices like what happens when you close the channel twice can be your choice! And going from STM to managing mutexes is a definite downgrade in abstraction power.
The concurrency design in Haskell feels like true magic.
The concurrency design in Haskell is cool, though I gotta admit that I don't find it much fun to write.
It's not because the language is "hard". I remember when I first learned Haskell a million years ago I thought it was the coolest thing ever because I had never seen anyone work at that abstract of a level before, especially in a compiled language. I got to understand the theory well enough and I know how to write a program with it, but the entire language kind of feels slapped together to me. Every time I've written anything in Haskell, I feel like I have to do a million compiler extensions, or rely on third party libraries' liberal use of Template Haskell (e.g. Lens) to make the language feel anywhere near "modern".
Yes yes yes, I know this is a complaint about GHC, not "Haskell", but given that GHC is basically the only Haskell compiler that gets serious use I don't think it's weird to conflate the compiler and the language.
Template Haskell is actually pretty cool. Using it to generate lenses in a type is a perfectly fine use case (of course hand-writing lenses is just one line anyways). Running computation at compile time is really a great feature; people rave about comptime in Zig but of course Haskell has had it earlier.
1 reply →
> the entire language kind of feels slapped together to me
The slogan "avoid success at all costs" definitely is accurate for Haskell
3 replies →
erlang would like to have a word.
It appears to me as if Golang has implemented part of the actor model. As I recall, it was neither set up to transfer free-form messages between the actors nor for the actors to persist beyond the given task.
Erlang has had both for over 20 years; it has also had green threads for equally as long—something I don't know if Golang has. I'm sure Golang cannot split itself to run on multiple machines with its actor model. Erlang can.
Go pulled more from the ideas of Hoare's Communicating Sequential Processes (CSP) than it did from actors. In particular, communication is synchronous (excepting the use of buffered channels, though even there if the buffer is full the sender blocks) and it uses channels, while the go routines themselves are anonymous and cannot be directly communicated with (to the point you can't even get a handle for them).
This is in contrast to, say, Erlang which is closer to actors than CSP, with its named processes (actors) and their mailboxes and no channels (though you can use a process as a channel).
From the creators of Go: https://news.ycombinator.com/item?id=48894637#48895160
I haven't found a Go concurrency thing yet that hasn't long-existed in Haskell before.
I also find Haskell's concurrency in practice much easier to reason about than Go's, let me do a pitch:
In Haskell you can just fork a thread and block till it's done. Threaded, "async" logic just looks like blocking serial code (but isn't blocking). I feel like in typical channel-based Go code I have to jump and scroll a lot in the code because of all the message-passing instead of block-scoped "blocking-style" variable use, and that this makes it hard to conclude whether the whole thing terminates or deadlocks.
In Haskell, channels are considered low-level concurrency primitives you should only use when you have no clean high-level primitives for it. This is because they are not "structured" concurrency: When you send something into a channel, it is gone out of your scope, and you now need to track in your brain where it is, and who should consume that thing in the right way ("message-passing").
For example, in Stolon, a high-availability Postgres orchestrator written in Go (https://github.com/sorintlab/stolon), I found the logic for failover with multiple channels and various timeouts very difficult to reason about when investigating failover bugs. I'm pretty sure that would read much easier in Haskell (see below how).
In Haskell, you can start 2, or N, things in parallel, and easily wait till they are done. You can invoke parallel `map` easily.
If f throws on any element, the whole map throws, and other threads get cancelled automatically as expected.
You can get bounded, steaming parallelism, easily.
You can set time limits to function calls writing
You can cancel any thread or computation, at any time. The same timeout function can cancel blocking IO operations, such as reading from the terminal or sockets, without having pass around `Context` objects like in Go (which, if you forget it, just makes things hang or deadlock).
You wrap the 2 words "timeout 1000" around your function and done.
Concurrency _composes_ in Haskell. You can write
and the returned type tells you cleanly at which level the cancellation occured (no mixing into the same `error` type.
You can build trees of parallel operations that live and die together.
And there are no data races (because mutability is a very explicit thing), and I'm not even mentioning STM here (which allows you to do database-style transactions across variables) because that's already pointed out in another post.
As a composed example, in Haskell you can write:
and that will do exactly what you think it should, with correct Ctrl+C cancellability, and good developer ergonomics.
If you enjoy concurrency, give Haskell a shot!
any downsides to it in your experience?
Managing channels and making sure they are closed just once is quite messy compared to other languages. The Go channel axioms[1] don't make much sense: why does closing a channel multiple times panic, but reading from a closed channel returns a zero value?
Kotlin gets this right. On send/receive, you can use trySend or tryReceive if you want to avoid exceptions. Considering Kotlin also has coroutines and structured concurrency, concurrency in Kotlin feels more ergonomic to me than Go. At least if you want to get concurrent code with least amount of bugs and not just least amount of extra keywords.
[1] https://dave.cheney.net/2014/03/19/channel-axioms
> why does closing a channel multiple times panic, but reading from a closed channel returns a zero value?
Not only that, but writing to a closed channel also panics. You need to close it exactly once from the sender side, and then somehow differentiate on the receiving side between an explicit zero value being sent, the channel being empty but not closed, and the channel being empty but not closed. It's not clear to me how this is possible without either using another channel (and then basically repeat the same problem on that new channel) or use some sort of shared memory like an atomic bool, at which you're no longer purely message passing.
I don't have any qualms with shared atomic primitives for synchronizing concurrency, but it's kind of weird that everyone talks so much about goroutines and channels when channels have such a weird design. Needing to use a separate mechanism to circumvent completely avoidable design issues for anything more complex than "never close the channel" does not seem particularly praiseworthy to me.
3 replies →
Yeah, channels are the main pain point. In addition to the axioms being simply weird (because it's an easy set to implement), another major problem is that you're essentially forced to use them because they're the only things that can work with `select`, and that's the only reasonable option for many operations. Especially if you touch other code, like the stdlib.
That and the lack of tooling around mutex usage / concurrency correctness. The race detector is legitimately excellent and every language needs it, but it can only catch races that you trigger in tests/builds with it enabled, and few projects write anywhere near sufficient concurrent tests to catch issues in practice. There isn't even a "this var claims to be protected by lock X, but it is not held [here]" lint, or "this var is atomic but used non-atomically [here]" (though this one is significantly less of an issue with generics, as safe zero-cost abstractions now exist).
2 replies →
Once I understood the idioms of Go channels they make sense, but those axioms, while true, aren't the idioms. The idioms would be something more like:
Channels are all intrinsically multi-producer, multi-consumer, and unbounded in size (which is to say, they can carry an indefinite number of messages, not related to channel buffering), but you should still know what the characteristics of your channels are, namely, single or multiple producer and consumer and whether there's some sort of bound on the number of messages. Particularly because you should only ever close a channel if it is single-producer and you are the producer.
It is OK to only use a fraction of a channel's power. For instance, a single-producer, single-consumer channel that is guaranteed (by code, not type system) to only ever have either 0 or 1 messages sent on it is a fairly common pattern.
Never just buffer a channel blindly to try to fix a problem. You should only ever buffer a channel with a size that corresponds to something particular; I know this may receive exactly N messages, 1 from each of N threads, and I want to decouple the possibility the receiver will give up early without hanging the producers, or something like that. Never just slap down a "10" or something and hope it makes things better. The vast majority of channels should be unbuffered.
Putting those two together, the correct way to tear down complicated structures after an error or something is often a channel whose sole purpose is to indicate the liveness of the system in question. In any even remotely modern Go, that should actually be a context.Context and not a channel, which is still basically "a channel with a defined close mechanism" under the hood but adds some other features that are almost always useful at some point.
The reason for all of the above is the select statement. You can in some sense look at "select" as the dual of the channel (being a bit free with the term "dual" here) and consider its functionality as the functionality the Go runtime is actually trying to provide you, from which the characteristics of channels are derived. From this point of view it is then trivially obvious why sends and receives to nil channels block forever... "block forever" is the channel-focused way of seeing the dual statement "the select statement will never select this channel". Some of the other details of channel behavior make more sense if you view them from the select side of the coin.
From this we can also derive a rule of thumb in Go, which is, if your "concurrency" is never going to be involved in a select, it probably doesn't need to be a channel. For example, a simple atomic counter really shouldn't be wrapped behind a channel with a goroutine reading from it or something, just use atomic integers. I have a number of mutexes in my real code. However, never ever take more than one mutex at a time. As soon as you feel like you need to do that, switch to channels, and a proper architecture that uses them somehow to do whatever it is you are trying to do.
(Trying to take multiple mutexes at a time is what led to threading hell in the 1990s. Contrary to popular belief, not just the mere act of threading, but the attempt to do so based on taking multiple mutexes, which at the time was thought to be the only technique available by a lot of the community, leading "threading" to take the heat for what should have been laid at the feet of "taking lots of mutexes at a time in one thread".)
I don't know much about Kotlin, but your cite of "trySend" and "tryReceive" makes it sound like you can do that on only one channel at a time. The fundamental thing about Go channels is that they can be put into select statements which can atomically send from or receive from multiple channels at a time, guaranteed to select exactly one of the possible outcomes. Many "I implemented Go concurrency in X" (often C) flop here. Some kind of queue than can be sent and received on is ubiquitous. Go channels aren't unique, because by the time Go came around pretty much every primitive had been tried somewhere, but it is to my knowledge the only langauge that lifted channels up into the language itself and made them first class.
But stepping up one level of abstraction, "lifting up a particular concurrency primitive to the language level" is not itself anything particularly special and I'm not claiming it is. For instance BEAM had a very particular concept of "mailbox" that it had lifted up into the language and runtime around 15 years earlier, which I have compared and contrasted before here: https://news.ycombinator.com/item?id=34564228 which is, overall, a richer concept than Go's channels, particularly because of its ability to pluck messages out of the mailbox out of the receiving order. Whether that richness is a good thing is something that could be debated a lot.
2 replies →
To the concurrency/threading; no? (there might be an "it depends situation somewhere idk about)
But Go itself comes with it's own runtime built into the final binary. It doesn't work well in some use-cases, I mentioned some of the draw backs in a couple other comments if you want to dig those up.
Also I saw some of the other comments. Channels are ultimately just used for message passing and aren't that complicated. You also can use mutexes or some other locking pattern. There's some primitive atomic structures available that solve some use-cases preventing you from even have to having to really deal with working between goroutines.
Others have mentioned the main issues, but to add; you often end up writing “ugly” code to do basic concurrency operations. Often setting up channels or workgroups then a `go func {}(…); wg.Wait();` just feels wrong and makes you thing “I must be doing something wrong, there must a better way, but that’s IS the way. It’s just go syntax quirks at the end of the day, and makes you appreciate go’s simplicity over high abstractions.
In my experience the main hurdle was getting developers on the team onboard with go’s way. It felt like swimming upstream for my 6 year stint in go. I was in a very Java heavy “enterprise” but we were writing a kubernetes operator and I pushed to use golang because (a) I liked it, and (b) it was 2019 and the entire kubernetes ecosystem was primarily go.
To me golang was very simple and I drank Rob Pike’s and Google’s narrative of how easy it’s to get a competent “compute science major in college”-person to pick up go. What I experienced was a form of “you can’t teach an old dog new tricks”. Lazy (and I hate to use this word) developers who gotten so used to frameworks and IDEs doing all the heavy lifting for them in Java or C# had 0 appetite forgetting all the questionable patterns they learned over the years and adopt Go’s simplicity. It was very frustrating at time, yet gave me a good eye for the actual skilled talent in the organization vs the average enterprise developer persona.
> who gotten so used to frameworks and IDEs doing all the heavy lifting for them
It's almost like those frameworks then achieved their job. Why do you assume you can write better code than what was iteratively refined over years, especially when it's usually not even directly related to any kind of business goal you may have?
1 reply →
Go and Julia are fun languages.
In production, Go has proven solid for several years. It is best when used with the native code people ported.
There are only two issues I encountered:
1. getting the legacy ancient C source meta-circular Go compiler working to port the Go boot-strap compiler upgrade chain is a kick in the pants. However, once it is on a architecture it has proven rather resilient.
2. memory limited systems can develop reliability issues, as Go programs will often ungracefully throw hard to diagnose unrelated errors during each crash. A good metric is 3:1 of your average load as a safety margin (if you see 2GiB in average RAM use, make sure to over-provision the host with 8GiB RAM etc.)
Other than the above short list of edge cases, if you join a pure Go project it is usually pretty reliable. Most community folks interested in the language seem fairly competent at building stuff that is fun. =3
I found Go memory issues easier to solve than Java issues. You can see an example with etcd used in Kubernetes. I had to enable performance profiling in etcd to identify why it was eating up all the memory. It led me to a specific partition of keys that tracked back to a specific object type in Kubernetes.
It was literally enabling a flag and running some commands to do some really quick exports.
Dealing with the JVM though, heap dumps are slow to process and the UI I had to download was very clunky. I don't know if there are better tools, but even if there are the path to just doing it isn't straight forward.
6 replies →
> if you see 2GiB in average RAM use, make sure to over-provision the host with 8GiB RAM
in this economy?
1 reply →
It's not memory safe: https://www.ralfj.de/blog/2025/07/24/memory-safety.html
Java is doing it better (virtual threads + structured concurrency + immutable records and immutable value types).
Not to mention golang is not memory safe: https://www.ralfj.de/blog/2025/07/24/memory-safety.html
Java's virtual threads are not preemptive. Yes there is a paper where they call them preemptive but they define the term differently to claim it. Code stuck in a tight loop is not preempted.
Well, go preempts at function calls, does it not? So a CPU-heavy inner loop calculating everything will fail to preempt in both languages - is this really a hill worth dying on?
Quite obviously the meaningful distinction is from manually inserted preempt points, like async/await languages.
1 reply →
I don't think you understand the threading concurrency topic. Also memory safety is so far off base here, where's that coming from? Java does some stuff okay, but do you really want to defend the horrid JVM problems? Also why can't I have my memory back when it's not in use in tightly packed systems?
It's not great for everything and neither is Go. You can find a bit more context on that in some of the other threads.
> defend the horrid JVM problems
Such as? It's one of the most widely used platform for backend services, basically almost all top 500 company has some business critical infrastructure running Java. It surely can't have "too horrid" problems..
3 replies →
> Also why can't I have my memory back when it's not in use in tightly packed systems?
That is changing as we speak:
[1] https://openjdk.org/jeps/8359211
[2] https://openjdk.org/jeps/546
[3] https://openjdk.org/jeps/8350152
> Also memory safety is so far off base here, where's that coming from?
It's coming from Go. In presence of data races on interfaces, slices or maps your memory might get corrupted.
> Also why can't I have my memory back when it's not in use in tightly packed systems?
You can. You have to either set your GC to be more aggressive or you need to utilize value types more.
2 replies →
Memory safety ≠ memory efficiency.
Not really, it is not knowing enough programming languages and computing history.