Metronome's Cacophony (9/14) - concurrency in Go - Channels introduction

Asynchronous approach (ctd)

Channels

Channels are extremely important in Go. They require shift of concurrent system design paradigm but they also ease you into it. I'm not going to scrimp on space to introduce them.

What are channels in Go?

Channels are the pipes that connect concurrent goroutines.

You can send values into channels from one goroutine and receive those values into another goroutine.

-- gobyexample.com

Speaking of their properties and usage, channels are FIFO constructs. They are safe to use in concurrent model for storing and passing values between Goroutines. You can also use channels to make Goroutines wait and resume operation.

Creating a channel, sending and receiving messages

Let's have a look at an example:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   words := make(chan string)
 7
 8   words <- "hello"
 9   words <- "world"
10
11   fmt.Println(<- words)
12   fmt.Println(<- words)
13}

Conceptually, the intention here was to send two words to a channel, then retrieve all messages from that same channel, and print them.

We have created a channel with keyword make, indicating we want a channel where the message type is string.

Then mysterious operator <- appears. This operator is used for both; sending (when it follows the channel variable) and receiving (when it precedes the channel variable) to/from a channel. In our code we are first sending two messages, then we are retrieving two messages with the same operator.

You need to know that sending messages on a channel that cannot take more messages will block. Similarly, trying to receive messages from an empty channel will also block. That property alone may give an idea to use a channel as means of synchronisation between concurrent tasks. It is one of its applications.

Output of the example:

1fatal error: all goroutines are asleep - deadlock!
2
3goroutine 1 [chan send]:
4main.main()
5	/home/seb/.GoLand2018.1/config/scratches/scratch_18.go:8 +0x5c
6
7Process finished with exit code 2

The error stack trace points to line 8, where a deadlock is detected.

Note: in general programming terms, deadlock is a condition where a task waits for a completion of another task, but it never happens. The other task can wait for the effect of the first one or there may be a circular dependency between multiple tasks. It is definitely easier to spot potential deadlock between two tasks.

In this case we wanted to send "hello" string down the words channel. The execution stops at this point because we have created an unbuffered channel. Unbuffered channel does not come with any space to hold the messages until they are received. Unbuffered channel needs to have a receiver waiting for a message at the time of sending, if you don't want to be subjected to blocking behaviour on sending end. It seems limiting but is actually very useful, just not in this use case. We are, indeed, creating a receiving construct later (lines 11 and 12), but that whole code is synchronous (executes sequentially). That means the receive would have started after the sending operation, if it ever got there.

There are two obvious solutions to this, either attach a receiver to the channel before sending or create buffered channel. I'll create a channel with buffer size of 2. That size will accommodate both messages and the send operations won't block:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   words := make(chan string, 2)
 7
 8   words <- "hello"
 9   words <- "world"
10
11   fmt.Println(<- words)
12   fmt.Println(<- words)
13}

Output:

1hello
2world
3
4Process finished with exit code 0

Ok, we got the expected words printed out with no error message and return code is 0. We have just used a channel to store some messages.

But wait, we have created a resource (channel) but haven't destroyed or closed it!

Closing and draining a channel

I've changed the example to make use of a very handy for-range construct:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   words := make(chan string, 2)
 7
 8   words <- "hello"
 9   words <- "world"
10
11   for word := range words {
12      fmt.Println(word)
13   }
14}

For-range loop, when used with channels, will drain it. That means it will retrieve all messages and keep waiting for any more.

Output:

1hello
2world
3fatal error: all goroutines are asleep - deadlock!
4
5goroutine 1 [chan receive]:
6main.main()
7	/home/seb/.GoLand2018.1/config/scratches/scratch_18.go:11 +0x128
8
9Process finished with exit code 2

Another deadlock! At least we can see the evidence of channel draining, as the messages are printed.

This deadlock is caused by the fact that the program won't end until for-range loop finishes. I didn't mention above that in order to leave this loop the channel has to be closed (as well as be empty). Obviously you still have the option to explicitly end the loop with appropriate branching statements (break or return), but that has to be done before the for-range loop enters the state of waiting for a message.

Closure is accomplished with close(channel).

Correction:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   words := make(chan string, 2)
 7
 8   words <- "hello"
 9   words <- "world"
10   close(words)
11
12   for word := range words {
13      fmt.Println(word)
14   }
15}

It will give you the correct messages printed, an error-free result with return code of 0.

Notice that I've triggered close() on a channel before the messages were read. It is a correct Go idiom to close a channel once you are sure there is no more messages to be sent. If a channel is closed before all (or even any) messages are received, the for-range loop will keep draining it, until last message is received and then the loop will end.

Closing channel is an irreversible operation.

Note: closing a channel in Go isn't necessary for the purpose of garbage collection. It is very useful as a control signal and, as such, it can prevent garbage collection problems (if your Goroutines wait for channel closing). Reference.

Note: If a channel is closed and, subsequently, you attempt to send a message through it, it will panic. I came across a lot of negative or confused comments about this aspect. While I had a concern for it at first, once I adopted the principle "producer, not receiver, closes channel", my life became much easier. That design intention (or constraint) becomes clearer once you start working with directed channels, as attempt to close channel declared as receive-only e.g. readHere <-chan (as opposed to writeHere chan<- or readOrWriteHere chan) will produce a compile time error.

Receiving on an empty and closed channel

Example:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   numbers := make(chan int, 1)
 7   
 8   // send number 0 as a message
 9   numbers <- 0
10   
11   // close channel
12   close(numbers)
13
14   fmt.Println(<-numbers)
15   fmt.Println(<-numbers)
16}

will return

10
20
3
4Process finished with exit code 0

Did you expect to receive 2 lines with zeros?

In the above output, only the first 0 is a genuine int message of 0 (which we've sent a couple of lines above).

After the channel is closed and drained, subsequent receives will come back with zero-equivalent value for the type. For int it is just 0.

You may wonder how to differentiate the above scenario from genuine 0 sent as a message? Here you go:

 1package main
 2
 3import "fmt"
 4
 5func main() {
 6   numbers := make(chan int, 1)
 7   numbers <- 0
 8   close(numbers)
 9
10   msg, ok := <-numbers
11   fmt.Println(msg, ok)
12
13   msg, ok = <-numbers
14   fmt.Println(msg, ok)
15}

Output:

10 true
20 false
3
4Process finished with exit code 0

So far we've used the operator <- to just retrieve the message. It is crucial to know it returns two values, first being the message, second a bool set to false if the channel is empty and closed (i.e. drained). With this in mind the above output should be clear.

Note: receiving from closed channel never blocks. Sending will also return immediately, but will panic with "send on closed channel" or similar error message.

Note: if your Goroutines wait for a channel to be closed (e.g. using for-range or <- channel) then close(channel) becomes quite a natural way to end a Goroutine gracefully. It is important to think under what circumstances and how your Goroutines will end.

Note: You can may be disappointed to learn that you cannot check if close has been invoked on the channel without attempting to retrieve a message. To cope with this constraining property you need to adapt your design so that it does not matter anymore. If you are using a buffered channel you can check its capacity with generic built-in function cap() and number of queued messages with another generic purpose function len().

Conclusion

I admit that I've painted a bit of a gloomy picture because even the simplest of examples contained blocking operations, occasionally leading to deadlocks. There's also a risk of causing panics. It becomes quite clear early on that great care has to be taken in order to use channels. This is not to say that any other concurrent model allows you to carelessly roam free.

Channels' natural blocking property is very useful in many asynchronous scenarios. It will help you coordinate stop of one and start of another Goroutine (synchronous equivalent), easily drain channels, pause execution while awaiting for more messages, creating equivalent of mutexes or semaphores, merging multiple threads of execution (joining) and more.


Other posts in metronome-cacophony series