Yeah, the code in context around locking the close of the done channel is a pretty common solution to a problem people often run into. The problem is that <-closedCh works perfectly -- it tells you that the channel is closed, but close(closedCh) and closedCh<- panic. There are a lot of hacks around that but the reality is -- you can't have an API where you intend for someone to close a channel that someone else is writing to. Only the writer can close the channel.
The core library's code kind of demonstrates the tradeoff; they use three concurrency primitives (!) in the same function to provide the API they want. The question is whether or not ordinary application code wants to debug that. It's worth it when it's being run billions of times a day by hundreds of thousands of people. It might not be in your one-off application.
I also worry that in the common case, people don't even consider the idiomatic solution before bringing out the esoteric solutions. I used to use a library that did this:
type counter struct { sync.RWMutex; value int64 }
func (c *counter) Inc() { c.Lock(); value++; c.Unlock() }
func (c *counter) Get() int64 { c.RLock(); defer c.RUnlock(); return value }
I had a few hundred goroutines that all contended on the write lock, and throughput was basically nothing. I think I was using less than half a CPU on a 128 thread machine, and the workers did actual CPU-intensive work... but not when they were asleep waiting for this mutex.
I eventually rewrote the monitoring library to use atomic.AddInt64(&counter, 1), and the throughput came back.
But in retrospect, I sometimes wonder about the actual costs of this. The hardware write barrier is somewhat insidious in that your profile won't show you "hey, everything is sitting here asleep" as it will with a mutex; instead your cache performance is subtly reduced and you'll never be quite sure how fast it could have been if you didn't have the synchronization there.
The ultimate best performance likely comes from doing a little more work and having a less-nice API:
type counter struct { in, out chan int64; value int64 }
func (c *counter) watch() {
for {
select {
case x := <-c.in:
value += x
case c.out <- value:
}
}
}
func (c *counter) Get() int64 { return <- c.out }
// elsewhere
go c.watch()
// in the writers
...
var buf int64 // goroutine-local counter
buf++
select {
case c.in <- buf:
buf = 0
default:
buf++
}
...
I have never bothered with this for counters, but this pattern shows up pretty regularly in other things. I have used this in streaming RPCs, where I could tolerate a little bit of latency in exchange for putting more data into one message. (One goroutine sends the message when it's big enough or enough time has passed; the writers just add messages to the channel or accumulate them for the next RPC.) It looks exactly like Nagle's algorithm for TCP, but at the application level.
Anyway, my conclusion from all of this is that application developers want something higher level than any of (atomic.*, sync.Mutex, or channels). They want multi-producer/multi-consumer, message queues, condvars, etc. Having only primitives result in a lot of wheel-reinvention by people that haven't thought enough about how wheels work.
I also want to put a rant in here about "buffer bloat" and how people will, by default, tolerate unbounded latency and unbounded memory usage to put something in a buffer instead of saying "the system cannot answer your query right now" or reducing throughput in a benchmark. It's a big problem on the Internet -- your SSH session and video conferences consistently lag because the designers of the system aimed to please reviewers, who only test throughput and not latency. If you want throughput, you can have an infinitely-long buffer -- and many network devices do. The results are not good. But I digress.