it always shocks me when someone says that channels are not immediately intuitive. they make so much sense to me, and they did from the instant I read about them.
combined with goroutines, channels are absolutely one of the best features of this language, to me.
async & await, on the other hand, still confuse me and trip me up today, even after using them for years, because of very weird implementation minutia and edge cases that I always seem to stumble into.
I wish async and await would disappear from the face of the earth and I wish coroutines and channels were in every language I use.
I've been using Go avidly since 2012. I still run into deadlocks and panics (writing to a closed channel) when I try to use channels beyond the simplest use cases. Error handling in a parallel context is also hairy with channels. I've learned to minimize my use of channels and prefer mutexes as my default concurrency primitive.
It really only means that CSP looks nice on paper but (at least as implemented in Go) doesn't work out well in practice. There's lots to like about the language even if the CSP theory didn't pan out.
My understanding is that if you follow the rule of using generator functions (creator of the channel is responsible for writing and the closing) it’s impossible to write to a closed channel.
The rule that the writer should be responsible for closing a channel is a good one to keep in mind, but it is often the case that you want to launch an indeterminate number of generators and collect the results from all of them. For example, you may want to make one connection to each server in the config, or to process each file in a directory in parallel. Because the arms of a `select{}` are determined at compile time, it cannot be used to select over the variable generator-owned channels, and you have four more difficult options:
* Use `reflect.Value.Select`[1]. Having to reach for reflect feels ugly for such a common case, and the performance of the reflect-select is much lower than the native select.
* Create a single channel owned by the reader, pass it to each writer, and arrange for this channel to be closed when the final writer exits, through a waitgroup. There is an example under "Parallel digestion" in the Go Concurrency Patterns blog post[2]. Note the little details to get right. We must launch a separate goroutine to monitor the waitgroup / channel closure. If we accidentally do it in-line at the wrong level, everything will work fine if the total number of items written to `c` is less than `c`'s capacity, but will hang once a worker becomes blocked on `c`. Additionally, the waitgroup is threaded directly into the writers, which may be more difficult if those are implemented in some other generic package.
* Wrap the above pattern up into a `merge` function, such as the one under "Fan-in, Fan-out" in the Go Concurrency Patterns post[2]. The lack of generics means we will have to copy-paste this function everywhere we want to use it. Additionally, this launches a goroutine for every channel being watched, which strikes people as "expensive" for such a simple operation.
* We can construct a function that takes two channels and launches a goroutine that selects between the two and writes to a merged output channel. By constructing a tree of these we can merge an arbitrary number of channels. This is really just an optimization of the above.
None of these options are particularly intuitive. Too often I've instead seen developers create a single channel owned by the reader and either:
* Assume it is never closed and the reader doesn't terminate until the application does
* Rely on some external mechanism to know when to stop reading. If the reader can stop reading without confirming that the writers have stopped writing, this can lead to the writers becoming blocked on sending into this channel, which may prevent them from performing necessary cleanup actions (signaling `.Done()` on a waitgroup, for instance) that cause hangs in other areas.
* Thread a cancellation ctx through every reader and writer. This ensures that nothing hangs, but can result in messages that are sitting in the the channels being dropped. If other areas of code have an assumption like, "every accepted request will receive a response", this can break that.
In addition, many developers have a gut instinct to add some amount of buffering to their channels, which usually results in these backpressure / channel issues being papered over during low-load unit tests, only to rear their head during higher load integration tests or in production, when the debugging story is much more difficult.
Channels cannot be effectively owned by their reader(s), the contortions you have to bend the code into to make that work never really make sense. That's just a constraint of the type, but it's hardly a problem -- it makes the thing easier to model. So this isn't really an option on the table.
> a Merge function
Yes! The answer. And goroutine per channel is kind of the point of using them! Nothing inefficient about it.
> a function that takes two channels . . .
Now there's some inefficiency! ;) No reason to do this, given Merge.
--
> None of these options are particularly intuitive.
The merge option seems perfectly intuitive to me, assuming you understand channels have to be owned by a singular writer.
This kind of trouble is exactly why Rust's channels feel much more intuitive to me than Go's.
With channels in Rust, the channel is closed when either all senders or all receivers are dropped. This means that doing the default obvious thing is also correct, for a much larger set of tasks than made easy by Go's API choices, and it stays correct under refactoring.
> I still run into deadlocks and panics (writing to a closed channel) when I try to use channels beyond the simplest use cases.
Wild!
The rule is that channels are owned by a single goroutine, who's uniquely responsible for sending on them, and closing them. That's basically it. Do that and everything works fine, in my experience.
same, I like go and I like channels in theory but they are too primitive in practice. I am much more likely to use waitgroups and errgroups than anything with raw channels
Channels as a high-level abstraction are pretty simple, but API and implementation details matter.
Go's implementation of channels are very simple to use for some very simple use-cases, but Go's API choices mean there are a lot of subtle, non-obvious details you need to learn and keep in mind to do anything nontrivial.
I really like https://medium.com/justforfunc/why-are-there-nil-channels-in... as an example. Reading from two channels safely should be a simple task, but just doing the intuitive thing will look like it works for many uses until it starts fabricating zero values, or blocks forever, or spins the CPU at 100% doing no work.
I really love channels, but I really hate working with Go's channels.
The channel API in that other language well-known for its good concurrency support is much simpler to learn and use for me, without as many subtle sharp edges, but that's possibly a bit off-topic, so I've removed a detailed comparison.
Sure, it's all out there, and it's possible to build useful software using Go's channels.
I was specifically trying to explain why I say that Go's channels are not intuitive, because they require studying and memorizing these other arbitrary complications.
I'm also curious about what docs you're referring to, exactly. Here's what I found when looking for golang channel docs:
If the documentation's described behaviour, along with code patterns to accommodate that behaviour, are intuitive to you after reading this, then you have a very different perspective on the world than I do.
Unless I've missed it in my reading, I don't see any of these clearly stating that the single-return form of channel receive will fabricate zero values when misused, or describing how you need to replace a closed channel with a nil when selecting on multiple channels to avoid spinning the CPU when it's been closed.
I agree that this stuff is learnable. I have learned it, and so have you. I agree that there are learning resources out there that help with learning the nuances of using Go's channels well.
Hopefully this can help you feel less shocked the next time someone says that Go's channels are not intuitive. If you disagree, can you explain more about how Go's channel management choices are more intuitive than the alternatives to you?
> Unless I've missed it in my reading, I don't see any of these clearly stating that the single-return form of channel receive will fabricate zero values when misused, or describing how you need to replace a closed channel with a nil when selecting on multiple channels to avoid spinning the CPU when it's been closed.
combined with goroutines, channels are absolutely one of the best features of this language, to me.
async & await, on the other hand, still confuse me and trip me up today, even after using them for years, because of very weird implementation minutia and edge cases that I always seem to stumble into.
I wish async and await would disappear from the face of the earth and I wish coroutines and channels were in every language I use.