3 ms·
When I first came to Go, I'd heard a lot of people speak profusely about CSP and how Go made concurrency easy. I was disappointed at just how not easy it was.
by atombender 6y ago
When I first came to Go, I'd heard a lot of people speak profusely about CSP and how Go made concurrency easy. I was disappointed at just how not easy it was.
For example, Go has no generic support for atomic primitives, arrays, slices, or maps. It has sync/atomic, but it's not generic. I always reach for Uber's atomic library, which has typesafe atomic wrappers such as atomic.Bool. They're so common, you'd think a CSP-aware language would provide keywords declare things atomic:
var b atomic[bool]
if cas(b, false, true) {
...
}
"Classical" Go didn't have errgroups, contexts or cancellation. To build something truly robust, "modern" Go ends up involving all three (plus atomics, of course). That's because almost all complex situations need to build what are effectively nested trees of goroutines that all need to quickly abort and unwind on errors or panics.
But as you use more and more of these primitives, your code (or at least my code) becomes more and more obscured by the layers of error-handling, cancel-detection, retrying, and so on. I really wish some of this was built into the language, especially cancellation. For example, channels don't support contexts. So if you have a loop like this:
for evt := range taskCh {
handle(evt)
}
...then you have to rewrite it to support cancellation. So you have a few options. One is to select on both the channel and context:
for {
select {
case evt := <-taskCh:
handle(evt)
case <-ctx.Done():
return ctx.Err()
}
}
Because of this, you can no longer use a for loop. Immediately the code got more bloated and less readable.
Another option is to spawn a goroutine whose only job is to abort the channel:
go func() {
<-ctx.Done()
close(ch)
}()
for evt := range taskCh {
handle(evt)
}
return ctx.Err()
That's a bit better (and you could move the for loop into its own function for clarify, without changing anything else). But it's still worse than a simple for loop!
As a real-world example, here [1] is some code I've been working on lately. A controller starts N workers that need to process a task queue. If the controller is stopped, all the workers need to stop. So I use the pattern above. But I can't use a for loop! That's because I use a task queue abstraction that needs to support features that Go channels don't.
I can't use a for loop or select block to block on the task queue, because only channels support that. So that's another problem with Go: Often, you can use raw channels as your data processing primitive if things are simple enough, but in practice, channels are best for coordination, and you have to build your own primitives — and yet, by building those primitives, you lose language expressiveness.
To address the concrete example, I'd love to do this instead:
// Range over task queue (custom object with blocking
// semantics), automatically cancel if ctx is cancelled
for task := range taskQueue with ctx {
handle(task)
}
[1] https://gist.github.com/atombender/6bcff2c2d8fec32bc80ce1f57a8e61f6 https://gist.github.com/atombender/6bcff2c2d8fec32bc80ce1f57...