A friend from work messaged me today that they had a hard time because they had used var c chan int
instead of c := make(chan int)
in their Go code.
I responded by saying that I usually have one rule of thumb i.e. to always use of make()
whenever I need a channel or map. That way I can be very sure that I can use those immediately.
They added that the surprising thing was it didn’t panic the program rather they ended up with an infinite loop that ran silently. I got more intrigued about the situation. So many questions started popping up in my mind. Why was I not able to catch it in the code review? Why was there no linter rule that could catch this? What is the point of having a nil channel in Go if I am brainwashing myself to always use make()
?
I went on to get some answers and here they are!
Nil channel
It is just a channel assigned to nil value.
1 | var c chan int |
Nothing wrong with it!
Send to a nil channel
When you try to send to a nil channel.
1 | var c chan int |
You get a deadlock.
1 | fatal error: all goroutines are asleep - deadlock! |
Receive from a nil channel
When you try to receive a value from a nil channel
1 | var c chan int |
You again get a deadlock.
1 | fatal error: all goroutines are asleep - deadlock! |
Send and Receive
Now let us try doing both from a nil channel.
1 | package main |
This ended up with deadlock too.
1 | fatal error: all goroutines are asleep - deadlock! |
But my friend mentioned they ran into an infinite loop and not a deadlock. How so?
The for select
construct
My immediate suspicion was a for select
construct instead of for range
construct in the above program.
1 | func main() { |
Now that leads to an infinite loop without printing anything! Because select
seems to not execute the case i := <-c
block when c
is a nil channel. What can it do after all? It can’t really receive anything from an un-initialized nil channel, right? So it ignores the case
block and always runs the default
block again and again.
Initialized channel
Now let us initialize the channel by using make(chan int)
instead of var c chan int
to see how our dear friend select
behaves.
1 | func main() { |
It was again an infinite loop, but this time the output was different.
1 | $ go run main.go | head -n 10 |
The zeros took over the output. I had to pipe the output to head
to stop the program from running infinitely and at the same time collect some sample output.
What are these zeros? Where are they coming from?
Those are arising from the case i := <-c
block of the select. When the channel is not nil, our select statement attempts to receive a value. That results in printing 1 2 3
, the three values that were sent to the channel. When we close a channel, all we get is the zero value. Hence we are getting zeros after that.
Is there a way to check if a channel is closed? yes, there is.
1 | for { |
We avoided printing zeros, but it is still leading to an infinite loop. Because the select is alternating between case
and default
blocks and continuously executes them.
Let us get rid of default
.
1 | for { |
That didn’t prevent the infinite loop, our friend select
is going on and on choose the case i, ok := <-c
block and performing the if condition that evaluates to false always as the channel is closed after sending 3.
The lesson
How do we avoid the infinite loop? Remember how the select statement ignored the case
block when my friend accidentally used the nil channel instead of an initialized channel at the start of this post? That is exactly what we need to disable the case in the select
statement.
1 | for { |
Now we are out of an infinite loop but are hitting a deadlock after the channel is closed.
1 | 1 |
Because after we disable the case, the select
statement essentially reduces to an empty select
clause.
1 | package main |
Makes the go routine sleep forever, there is no case statement that it can listen to for receiving a message.
The core lesson however is
nil channels are useful for disabling
case
blocks ofselect
I kind of arrived at this lesson in a weird way, but this just for func episode teaches it in a beautiful way. (Thanks Campoy if you are reading this!)
This is particularly useful when you are dealing with multiple channels in different cases of a select
and if you want to diable the case blocks one by one when those channels are no longer needed. Going to copy-paste the example from that justforfunc episode to capture the idea.
The problem is to merge values coming from two channels and output them in another channel.
1 | func main() { |
Now the merge
routine could listen on both the channels and disable the case for a channel after it is closed to make sure that we don’t spend any more CPU time on that case.
1 | func merge(a, b <-chan int) <-chan int { |
Beautiful, right?
Now back to our problem.
The solution
Let me solve the rest of the problem just for closure.
One way would be to break to an outer
label as shown below. That way,
1 | func main() { |
And finally, we get
1 | 1 |
NOTE: this would work without the c = nil
statement also.
1 | outer: |
If the above is same as the previous solution, what is the point? We noticed that setting a channel to nil
is beneficial when we have multiple cases. In here, I could maybe use that as a check for the for
.
1 | func main() { |
I should probably start brainwasing myself to make sure I set the channel to nil
after consuming it completely. That way I can avoid the weird break label syntax and disable select cases to get more throughput.
Anyhow I know of a simpler solution. So my recommended solution for my friend would be to
- initialize the channel with
make()
- use a
for range
construct instead offor select
construct.
That would look like:
1 | func main() { |
A proverb
The example that I gave was a very “trimmed down” version of what my friend was trying to accomplish in a real-world system. He was trying to consume a channel and split the messages into two other channels. The miss was failing to initialize the channels where the split was occurring.
On the other end, we learned from the justforfunc example that, when we try to merge two channels into one, we could start setting the consumed channel(s) to nil.
This is provoking me to make up a Go proverb of my own 😅 Please excuse me if it sounds bad! You have come so far. So you can’t escape from it now - lol :D
~ ~ ~ ~
“Init when you split, Nil when you merge.”