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.
I learned Haskell before that, and frankly the concurrency in Go is similar, perhaps a slight downgrade due to the lack of STM. (You can implement channels using STM, but you can also implement other things.) 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.
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.
> 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.
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.
It makes perfect sense when you think about the order of doing name lookup and type checking. In Haskell name lookup happens strictly before type checking. In more conventional 90s languages like C++ that’s obviously impossible because to compile `foo->bar()` the compiler must first know the type of `foo` before it can begin the name lookup for `bar` in the class hierarchy; in fact in C++ `bar(foo)` also requires knowing the type of `foo` before being able to look up `bar` due to the amazing feature of ADL. In Haskell the type checking was meant to be a research playground for new ideas, such as the idea of type classes, so naturally it was a bad idea to couple name lookup with type checking.
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).
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.
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 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.
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.
I can't imagine many situations where you have many cpu bound go routines. Most people aren't doing SPMD / open mp / similar in Go or other GC type languages. The main point is efficiently waiting for IO with an understandable programming model.
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.
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).
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.
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.
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...
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 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 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.
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.
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.
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.
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.
> 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.
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.
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.
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)
> The memory safety thing is just a moot out of scope contract.
Data races are not common but when they do happen. I hate to debug them. Only thing worse than data races is data races causing SEGFAULTS.
> Every Java application I've seen in the wild when I supported JVM seemed to never release any ram it allocated. Ever.
You can say the same for Go. Nature of GC langs is they consume more memory than what is minimal. And in theory as a trade off they give you memory safety.
Go doesn't even give memory safety. If program is racy enough.
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..
Still doesn't answer the question. In Java, allocate 8 gigs. Free 7 gigs. Why still 8 gigs? I didn't ask anything about if I could run a 1 gig process here.
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.
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 LLMs can't 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
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.
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.
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.
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.
> 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.
I don’t know why people criticize Go for not being a cutting edge research language when that was very explicitly not the goal. Lots of things were available in the research at the time, and much of that has gone ~nowhere.
Starting with what they knew to work and iterating from there is wise.
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 that’s too much for Go’s creators.
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.
No, this has nothing to do with processes. It has to do with the `context` package, which the best way to do goroutine cancellation in Go, but if you look how it works under the hood, it requires a lot of hacks, because there is no direct cancellation support for operations in the runtime. For example, every single `Read()` in the standard library would have to be wrapped by `AfterFunc()`, not all of them actually do it, so some reads are cancellable, some reads are not. It's all just best-effort with no guarantes.
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.
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.
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 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/
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.
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.
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
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.
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.
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.
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.
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.
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.
139 comments
[ 3.6 ms ] story [ 118 ms ] threadJust 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.
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.
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.
The slogan "avoid success at all costs" definitely is accurate for Haskell
"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.
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.
I’m not a historian but that’s just my thought.
Doesn't that describe pretty much any green thread style concurrency implementation.
Other languages and their implementations of green threads usually have cooperative scheduling or M:1 mapping
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).
I would guess that how well integrated OS threads or green threads are into a language has a much stronger impact on experience & quality.
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.
Also platforms like Wasm still do Mx1 scheduling without async preemption, where Gosched is required at places.
[1]: https://en.wikipedia.org/wiki/Green_thread
I believe Go didn't originally have it, and added it in 2020, 14 years after Haskell.
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.
https://blog.stenmans.org/theBeamBook/#_concurrency_parallel...
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.
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.
Once a sufficiently good proposal was made, generics were adopted.
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…
And most software does apparently not need what it offers.
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.
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.
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.
This poster summarized it well: https://news.ycombinator.com/item?id=49864334
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 think Go and the BEAM family languages are both great.
Truly something else if error handling is built into the language as a default case, not an … exception.
Not to mention golang is not memory safe: https://www.ralfj.de/blog/2025/07/24/memory-safety.html
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.
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.
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.
[1] https://openjdk.org/jeps/346
[2] https://openjdk.org/jeps/546
[3] https://openjdk.org/jeps/8350152
[4] https://openjdk.org/jeps/8359211
Data races are not common but when they do happen. I hate to debug them. Only thing worse than data races is data races causing SEGFAULTS.
> Every Java application I've seen in the wild when I supported JVM seemed to never release any ram it allocated. Ever.
You can say the same for Go. Nature of GC langs is they consume more memory than what is minimal. And in theory as a trade off they give you memory safety.
Go doesn't even give memory safety. If program is racy enough.
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..
Furthermore, collection is a function of the liveset, not the # of dead objects.
That is changing as we speak:
[1] https://openjdk.org/jeps/8359211
[2] https://openjdk.org/jeps/546
[3] https://openjdk.org/jeps/8350152
Quite obviously the meaningful distinction is from manually inserted preempt points, like async/await languages.
They fixed that in Go a while ago.
https://go.dev/doc/go1.14#runtime
Then they added structured concurrency, which roughly solves the same problem as Go's context, arguably more elegantly.
I have tried but I honestly can't go back to Go now, it's so much harder
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.
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 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!
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?
Starting with what they knew to work and iterating from there is wise.
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.
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.
Pony with actor model.
Erlang/Elixier on BEAM VM.
cf https://news.ycombinator.com/item?id=48894637#48895160
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...
Too many years of Java and managing Threads and Runnables probably rotted my brain.
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.)?
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.
I've started using golang last year and I feel like I'm missing exactly this kind of experience with these patterns
Spamming them all over the place is a red flag imo
A channel of size 1 is a bit like an mvar but with support for only take and put.
https://github.com/goyek/goyek
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.