← Back to context

Comment by chimbambum

1 day ago

any downsides to it in your experience?

Managing channels and making sure they are closed just once is quite messy compared to other languages. The Go channel axioms[1] don't make much sense: why does closing a channel multiple times panic, but reading from a closed channel returns a zero value?

Kotlin gets this right. On send/receive, you can use trySend or tryReceive if you want to avoid exceptions. Considering Kotlin also has coroutines and structured concurrency, concurrency in Kotlin feels more ergonomic to me than Go. At least if you want to get concurrent code with least amount of bugs and not just least amount of extra keywords.

[1] https://dave.cheney.net/2014/03/19/channel-axioms

  • > why does closing a channel multiple times panic, but reading from a closed channel returns a zero value?

    Not only that, but writing to a closed channel also panics. You need to close it exactly once from the sender side, and then somehow differentiate on the receiving side between an explicit zero value being sent, the channel being empty but not closed, and the channel being empty but not closed. It's not clear to me how this is possible without either using another channel (and then basically repeat the same problem on that new channel) or use some sort of shared memory like an atomic bool, at which you're no longer purely message passing.

    I don't have any qualms with shared atomic primitives for synchronizing concurrency, but it's kind of weird that everyone talks so much about goroutines and channels when channels have such a weird design. Needing to use a separate mechanism to circumvent completely avoidable design issues for anything more complex than "never close the channel" does not seem particularly praiseworthy to me.

    • 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.

      2 replies →

  • Yeah, channels are the main pain point. In addition to the axioms being simply weird (because it's an easy set to implement), another major problem is that you're essentially forced to use them because they're the only things that can work with `select`, and that's the only reasonable option for many operations. Especially if you touch other code, like the stdlib.

    That and the lack of tooling around mutex usage / concurrency correctness. The race detector is legitimately excellent and every language needs it, but it can only catch races that you trigger in tests/builds with it enabled, and few projects write anywhere near sufficient concurrent tests to catch issues in practice. There isn't even a "this var claims to be protected by lock X, but it is not held [here]" lint, or "this var is atomic but used non-atomically [here]" (though this one is significantly less of an issue with generics, as safe zero-cost abstractions now exist).

  • 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.

      Concurrent ML had something like that, and it was implemented in OCaml as well: https://ocaml.org/manual/5.5/api/Event.html

      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.

Go and Julia are fun languages.

In production, Go has proven solid for several years. It is best when used with the native code people ported.

There are only two issues I encountered:

1. getting the legacy ancient C source meta-circular Go compiler working to port the Go boot-strap compiler upgrade chain is a kick in the pants. However, once it is on a architecture it has proven rather resilient.

2. memory limited systems can develop reliability issues, as Go programs will often ungracefully throw hard to diagnose unrelated errors during each crash. A good metric is 3:1 of your average load as a safety margin (if you see 2GiB in average RAM use, make sure to over-provision the host with 8GiB RAM etc.)

Other than the above short list of edge cases, if you join a pure Go project it is usually pretty reliable. Most community folks interested in the language seem fairly competent at building stuff that is fun. =3

  • I found Go memory issues easier to solve than Java issues. You can see an example with etcd used in Kubernetes. I had to enable performance profiling in etcd to identify why it was eating up all the memory. It led me to a specific partition of keys that tracked back to a specific object type in Kubernetes.

    It was literally enabling a flag and running some commands to do some really quick exports.

    Dealing with the JVM though, heap dumps are slow to process and the UI I had to download was very clunky. I don't know if there are better tools, but even if there are the path to just doing it isn't straight forward.