logoalt Hacker News

jerf • today at 1:33 AM • 0 replies • view on HN

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.