| ▲ | unscaled an hour ago | |
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. | ||
| ▲ | Groxx an hour ago | parent | next [-] | |
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). | ||
| ▲ | jerf 31 minutes ago | parent | prev [-] | |
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. | ||