Back Original

Nil channels in Go

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 of select

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

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.”