Why I Don't Like Go Channels
I'm a big fan of the go programming langauge, this is well known. But when asked what about the language I don't like, my answer surprises people: channels.
Despite being a cornerstone of the language's design and a builtin feature from day one, I try to avoid channels. To understand why, let's first take a look at the different states a channel can be in:
- Nil
- Closed, Unbuffered
- Closed, Buffered, empty
- Closed, Buffered, not empty
- Initialized, Unbuffered
- Initialized, Buffered, empty
- Initialized, Buffered, partially full
- Initialized, Buffered, full
A total of eight, and that's ignoring that an unbuffered initialized channel behaves differently depending on if someone is receiving on it or not, and that a closed buffered partially full channel is semantically equivalent to a closed buffered full one (hence the "not empty" state). Now let's look at how many actions are possible on a channel, regardless of its state:
- Send
- Receive
- Close
Three. Multiplied by the number of states that results in 24 different interactions which can happen with a channel, and you, dear coder, need to remember all of them. This is too many, especially when it comes to a sensitive area like concurrency.
Is there any rhyme or reason to the possibile combinations which might help remember all the interactions? Let's take a look.
States || Actions ------------------------------------------||--------------------------------------------- || Send | Receive | Close ------------------------------------------||--------------------------------------------- Nil | | || Blocks | Blocks | Panics Closed | Unbuffered | || Panics | Returns zero value | Panics Closed | Buffered | Empty || Panics | Returns zero value | Panics Closed | Buffered | Not Empty || Panics | Returns next value | Panics Initialized | Unbuffered | || Blocks | Blocks | Closes Initialized | Buffered | Empty || Sends value | Blocks | Closes Initialized | Buffered | Partially Full || Sends value | Returns next value | Closes Initialized | Buffered | Full || Blocks | Returns next value | Closes
There's a couple shortcuts: You can only close an initialized, non-closed channel. Makes sense. And you can't send to a closed channel ever, though I had to double check for the buffered non-empty case because, intuitively, it would work... reading from a closed buffered channel works afterall! And this is from someone who's been a professional go developer for over 10 years.
But the point is that I don't know it off the top of my head, and that's because I try to steer clear of keeping this chart in my head at all. I do that by simply not fucking with channels unless I have to. What do I do instead? There's a few good options. In general I don't spawn off "actor" go-routines, i.e. a go-routine which is the "owner" of a piece of state, and which allows other go-routines to interact with that state via channel communications. This was the original idea of go-routines+channels, way back in go 1.0, and it was cute, but in practice it's a headache.
Instead I have each piece of state owned by a component, which is usually a struct with mutexes on it. Public methods on the struct grab mutexes to interact with shared state synchronously as needed. If the component requires some kind of asynchronous behavior, then it will own a go-routine for that purpose. The go-routine owns the _behavior_, not the state; it still has to grab the mutexes on the struct like everyone else. In cases where two behavioral go-routines, belonging to the same component, need to communicate with each other synchronously, only then does a channel come into play. And that's a pretty specific case... the vast majority of components can do without.
What would make go channels more ergonomic? Take a look at the left side of that table. That entire side is covered by a single type signature, i.e. it's basically impossible to know what state your channel is in just by looking at it. Maybe for closed vs initialized that makes sense, but for buffered/unbuffered? There's an optional type annotation to indicate read-only/write-only channel handles, why not buffered/unbuffered? And why is it necessary to have a nil channel at all? If anything writing to a nil channel should panic, to match the "zero value" behavior of slices and maps, but it blocks instead.
There's probably _reasons_ for why I'm wrong about all of this, but I doubt any of them are worth the headache. So instead I gripe on my website about how I don't like go channels, and do my best to not use them.
Hi! I'm available for remote contract work. You can learn more about me and my skillset by browsing around this site, then head over to my resume site to find my work history and professional contact form.
This site is a mirror of my gemini capsule. The equivalent gemini page can be found here, and you can learn more about gemini at my 🚀 What is Gemini? page.