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.
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.
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.
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 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.
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.
> 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.
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.
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.
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.
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.
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 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.
Not sure if purposefully but you left out java. It doesn't preempt a CPU hot loop, but arguably you don't really want to disturb the CPU there / the whole "green thread" model doesn't make much sense with that kind of workload. That's why you have ordinary threads as well.
That's why higher level languages are in an advantage. E.g. in java - outside of FFI - syscalls are basically "only within" standard library functions. So it was possible to make them virtual thread aware, e.g. park a virt thread, use something like io uring for the actual IO call on the JVM level and resume it when it returns, doing work on another virt thread in the meanwhile.
Before that, Go did preempt on function calls. Haskell preempts on memory allocation. There is no idiomatic Haskell code that loops without allocating, but it is possible. Go code that loops without function calls is probably a lot more common but still avoidable.
I wasn't comparing Go to every language ever, just the ones people are most likely to pick. In that group Go (and Elixir) have more unique concurrency models.
AFAIK, in the current implementation, Java's virtual threads yields only when they block (cooperative). But the spec allows a JVM to implement them as preemptive.
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.
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.
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.
I don't dispute the coolness of any given Haskell feature. Haskell does have a lot of really neat features, but that doesn't mean that the language is fun to use.
C++ also has a lot of really cool features but I also do not enjoy writing it, actually for similar reasons as Haskell (though I don't think Haskell is nearly as irritating as C++).
> of course hand-writing lenses is just one line anyways
The lenses themselves aren't hard to write; I was referring to the annoying quirk of Haskell where records couldn't have the same field names. Lens has a nice helper macro `makeFields` so that you could more or less automatically have the generated lenses have the clashing names.
To be fair it actually always worked fine for me but it always felt janky until `DuplicateRecordFields` was released.
I honestly don't think that that's why the language is annoying though.
"Avoid success at all costs" has always meant (in my mind) to mean "we will prioritize doing things the 'right' way instead of doing things to appeal to corporations". That's fine, I'm all for doing things correctly, but I think a lot of Haskell's bullshit isn't because of that.
It's a common complaint but it's common for a reason: the fact that records couldn't contain the same field names was really stupid. Apparently in the 90's the Haskell devs couldn't fathom two different types both having a field called "ID" or "name". How does allowing multiple objects to have an overlapping field name affect purity? Plenty of other languages, some of which are even more mathy than Haskell, have managed to pull this off (e.g. TLA+). Could it be because records/structs are really just a shitty hack around tuples tacked onto the language? Yes, Lens fixed hat particular problem with Template Haskell, and now there's yet another GHC extension to more or less work around it, but doesn't change the fact that I think it was something actively bad and it wasn't because of "purity" reasons.
My interpretation is more that changes solely for the purpose of wider adoption were explicitly "out of scope" for the language. Warts aren't undesirable, but in a certain sense actively embraced in my reading of the slogan.
> Apparently in the 90's the Haskell devs couldn't fathom two different types both having a field called "ID" or "name".
I think it's simply that making field names become functions that select from the record was a simple design that worked, and didn't require anything new to be added to the language.
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).
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.
results <- mapConcurrently f mylist
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
timeout 1000 (myIoFunction ...)
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
res :: Maybe (Maybe a) < timeout a (timeout b (myIoFunction ...))
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.
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.
> 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.
The easiest way to deal with receiving from a channel is using range over it in a separate goroutine. The for range finishes only when the channel was closed and all values were read.
Of course, that won't work if you want to receive from several channels in the same goroutine. For that you can use select with receive assigning to two values and the second one is set to false if channel is closed.
So, I never really had issues on receive side, I agree with the send side, though. The solution I used when sending from multiple goroutines is to wait for the senders to be finished in the "main" goroutine and only the close the channels.
But yeah, it does require some thought to be put into how this is all organized.
> The solution I used when sending from multiple goroutines is to wait for the senders to be finished in the "main" goroutine and only the close the channels.
How do you know when they're finished though? It seems like you're need to have an additional channel or atomic boolean per goroutine for this, which just increases the amount of organizational burden.
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).
Go monomorphizes quite a lot, so that's mostly incorrect - it'd be relatively true for Java, for comparison, ignoring Graal. https://github.com/golang/proposal/blob/master/design/generi... there are only exceptions when you instantiate multiple different types that share an underlying type/layout, all other cases (including different types) are monomorphized. E.g. `type x struct{a int, b float32}` and `type y struct{b int, a float32}` share codegen, but if you even just swap the type order (float32 then int) they wouldn't.
The primitive generic atomics in the stdlib don't run into those details, so you really do get pretty much exactly the compiled code as what you'd write inline by hand:
>In particular, fundamentally different built-in types such as int and float64 are never in the same gcshape. Even int16 and int32 have distinct operations (notably left and right shift), so we don’t put them in the same gcshape.
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.
> 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.
And I would say it even goes further than Go by making the actual events first class (no need for language support for this either): send, write and choose (aka select) are also events, so you can build your own things you can select on. Select would be basically defined as
let select events = sync (choose events)
so sync is the way to convert events to values (and blocking in the progress).
CML had one extra trick in its sleeve: it was able to garbage collect threads that were not able to proceed. I'm not aware of any other system that can do that. This would e.g. resolve leaking coroutines in Go, at least in some situations..
It’s not the first time I see you explaining Go concepts at this level of abstraction, focusing on “why” of the design. I feel like official docs are often like “here’s the API, use it”, and I often leave with the thought that it’s designed for the ease of the person doing the implementation, not the user. Thank you.
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?
Most go codebases I have seen would benefit from using a framework TBH.
Sure, not using a framework is nice for a small and simple service.
When you have multiple devs working on a large codebase over the timespan of years, a 'heavy' framework is highly prefereable over everyone reinventing the wheel.
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.
Observability on the JVM is second to none, especially with tools like JFR available for free. You can literally connect to a live JVM and introspect it.
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.
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.
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..
The memory safety thing is just a moot out of scope contract. It seems moot to me every time someone shows up trying to push memory safety everywhere, that's a language to developer contract issue, not a functionality issue. When the contract of the language is such as that of Go vs Rust, the two languages are just offering different contracts. Rust just promises to hold your hand more than Go does.
Regarding the JVM and GC. Good luck with that? Every Java application I've seen in the wild when I supported JVM seemed to never release any ram it allocated. Ever. If it used 1G and even after free, the JVM decided that was going to be used again and wouldn't release it.
It would be hard to sell me on wanting to use Java again (people can pay me enough to do it, but I hate it). Which kinda sucks since Apache Foundation has a ton of really cool projects using it. Kotlin maybe, but I have no real use cases where it would be better than anything else I know right now.
When was the lat time you encountered such issue? The JVM has been more proactive in releasing memory back to the OS[1], and more work on dynamically setting the heap size (both up and down)
Ive been writing Go for over a decade and I still feel like I never quite "got" channels. Every time I use them I need to go consult the manual, and none of the patterns feel obvious which is weird considering the rest of the language feels very obvious.
Too many years of Java and managing Threads and Runnables probably rotted my brain.
Channels are honestly one of the most over-used things in Go. I've been writing Go professionally since 2015 and I honestly rarely use them. Programmers new to Go love to shovel them in everywhere because "why use Go if you're NOT going to use channels?" and I have to say sorry, no - write it serially, then determine if it breaches your SLOs, THEN determine if concurrency fixes it.
Using Go since 1.0, agree wholeheartedly. Newcomers read the docs and start throwing channels everywhere because why not.
I always ask/tell people to write without channels, and only add them when you have justification for doing so. That leads to much more sane code.
One pattern I see often because random blogs mention it is starting X long lived goroutines, then passing them data via channels, then receiving responses via channels, then handling. In my experience, it's 100x less error prone to just use a semaphore to start a goroutine per data, and have them do their own handling. No channels involved.
Yup. Go maturity is realising how little you need to use channels and Goroutines. You probably just need a setup in one place, like in front of incoming requests ... which using net/http already does for you.
Spamming them all over the place is a red flag imo
Very interesting feedback. I'm a Go newbie and the goroutine/channel duality sounds delightful from where I stand, but once again I have no professional experience with Go yet, only sample programs to get used to the language.
One question though: your advice is to write things serially first before moving to concurrency, which for me is general programming common sense, but would you argue that once you start writing concurrent code then channels are not well suited compared to "good old" sync primitives (mutexes, etc.)?
There are a lot of places where channels look like the correct primitive but may actually be overkill. One of my favorite examples is collecting results from a group of goroutines. If you know the number of results up front, you can just define a slice and give each thread an index of the slice to write to (and a waitgroup of course). No channels, no mutexes, and completely thread safe.
I’d say it’s important to understand how they work but I also rarely find myself reaching for channels. I see more usage of wait groups and mutexes, but even then you can build abstractions around these in a way that can be reused without having to touch them again.
Concurrency has nothing to do with performance and everything to do with your domain. If what you're modeling is concurrent, your code should accordingly be concurrent also.
It depends really on what you actually want to do. I tend to make a few helper funcs for different kinds of things I want to do. For example, a helper funcs to accept anonymous job funcs and collect output. Then you can compose programs out of those higher level blocks.
Superior in ergonomics - launching several async tasks and combining their results via futures is is much easier compared in Java compared to to Go's low-level, primitive way of doing things. No need to explicitly create channels and wait on them. Go doesn't expose Go-routines as a type and hence you are brow-beaten into laboriously using channels even when there is no real need to do so. I guess this could be all sorted out if the Go stdlib offered some convenient structured concurrency packges.
Arguably, being 10 years late to the party is pretty bad.
Just how Go adding generics to the language didn't magically fix the billions lines of non-generic Go code, adding virtual threads to Java didn't update its entire ecosystem to take advantage of them.
Meanwhile, the entire Go ecosystem from the beginning took advantage of goroutines, so all code you'll ever interact with will have excellent support for them.
If you make use of a 30 years of library that does simple blocking IO and you call that library from a virtual thread you literally have non-blocking behavior - so your "didn't update it's entire ecosystem" is plain wrong. It's also just a Thread, so even consuming virtual threads by old libs is just fine.
Also, what 'party'? There is java, go, Haskell and erlang with anything similar. The majority of programming languages don't have such a feature so it's pretty questionable use of word to "be late".
For many people, besides learning what you should do, it is more helpful to read anti-patterns and things you should not do in Go, and none is better than this article about data race patterns in Go: https://www.uber.com/us/en/blog/data-race-patterns-in-go/
Ahh you got me. Finished the first chaper of the 'free online' Gist of Go book, then in the second chapter it turns out the first chapter was a freebie.
I used to do this, and do it well. Nowadays, I avoid it like the plague. Not just because of the advent of AI agents, but also. I usually try to condense the core business logic of the application into a tight sequencer, and then every type of slower workload has a manager for it, with queue, dispatching. All logic remains linear, easy to review and follow. Concurrency is basically just handled at the level of kicking off some work, and then funneling the result back into the sequencer. Easier to test, highly scalable concurrency.
This is fine, but it's too bad it did not mention the cardinal rule of goroutines on prod, which is "before starting a goroutine make damn sure you know how it will stop".
Goroutine leaks in prod are no laughing matter. They are difficult to debug without killing the process, and that's only useful if you are sure you're going to get stderr to get the full traces of all goroutines.
honestly the hard part of go concurrency was never starting goroutines, it's making cancellation and shutdown behave. nice to see context, races and diagnostics in one runnable place.
Yup. That’s because cancellation isn’t native, but part of the context object and requires cooperation. In a language where goroutine switching is preemptive rather than cooperative, I find it odd to have cooperative cancellation, until I realize that Go doesn’t have exceptions and probably will never have them.
One thing I always found more work than I would expect is when you have a graph of operations, think a Makefile, but a bit dynamic. For this model completable futures and executors seem to work well (provided the graphs is smallish), but golang is (or perhaps before generics) just was difficult.
A go channel is just a queue with a configurable amount of buffering. Buffering 0 is the most interesting as it creates a “rendezvous” channel which syncs the sender and the receiver.
A channel of size 1 is a bit like an mvar but with support for only take and put.
Go has a really good concurrency story. Its one of the best ones out there. Some langs have async/await (usually sucks) and some nothing att all (like php)
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.
reply