Diving into Golang channels

Diving into Golang channels

Add to bookmarks

Sun Jul 28 2019

Golang channels make it easy to send data across dimensions (boring name; goroutines) in a safe and controllable manner. In a previous article, I covered goroutines and gave a simple explanation to channels. This article aims to expand more on channels, an overview of how they work and some uses.

Ok, but what are channels exactly?

Channels are a typed conduit through which you can send and receive values with the channel operator, <-. golang tour

Pretty clear definition right? An even clearer analogy is they are the little green multi-dimensional phone lines that connect different goroutines (dimensions) in your golang app.

green threads! channel as green threads

Creating a channel.

This line of code creates a multidirectional channel that allows us to send int16 types and has a buffer of length 2 (More on these terms below)

// identifier := make(chan type, buffer_length)
numberChannel := make(chan int16,2)

Channel data flow.

A channel can be used for both sending and receiving data, depending on how it is declared. For instance, using the numberChannel we created above, we can send and receive integers from it using the <- operator; example:

// send '1' into the channel
numberChannel <- 1
// read from the channel into the recievedValue variable
receivedValue := <- numberChannel
fmt.Println(receivedValue)

The placement and direction of the arrow indicate whether the operation is a read or a write operation.

A channel can also be declared as unidirectional; as in, it only allows the flow of data in one way e.g

func printChannelGoroutine(integerChannel <-chan int){
    fmt.Println(<-integerChannel)
}

In this example the function signature receives a read-only channel as a parameter. If you try something like integerChannel <- 1 on the channel, you would be greeted with an invalid operation error.

When calling the function/goroutine a bidirectional channel can be passed in as a parameter

The same can be done for a send only channel:

func sendChannelGoroutine(integerChannel chan<- int) {
    for i := 0; i < 3; i++ {
        integerChannel <- i
    }
}

A unidirectional channel can also be created using the make command e.g

// this way values can only be sent into this channel
numberChannel := make(chan<- int16, 2)

Types of channels.

We have just two types of channels;

  • A buffered channel which has a buffer capacity greater than 0.
  • An unbuffered channel. Can be thought of as a channel with a buffer capacity of 0.

Parts of a channel.

In golang, channels posses a few (possibly a lot of) attributes, but this section of the article will only be focusing on three of those attributes:

  • A buffer that holds values
  • The count of items in the buffer: qcount
  • A send queue: sendq
  • A receiving queue: recvq

channel variable a representation of a channel during debugging in the Goland IDE

- what are all these exactly?

The value buffer of a channel does exactly what the name implies. It acts as the buffer and holds values being sent to the channel.

The send queue and receive queue are queues (first in first out) that hold goroutines who are trying to send data to and receive data from the value buffer respectively. Any goroutines on the receive or send queue are automatically blocked until they are moved out of the queue.

How does send work?

When data is sent into the channel, it is moved into the buffer and the goroutine proceeds and continues running as normal, but when the buffer is full, the goroutine trying to send to the channel is placed on the sendq until its value is ready to be read or until the buffer gets freed up.

Looking at the sample situation below

func main() {
    numberChannel := make(chan int,1)
    go func(){
        // loop through all values in the channel
        // break point here
        for i := range numberChannel{
            fmt.Println(i)
        }
    }()
    // send '1' into the channel
    for i := 1; i<3; i++ {
        numberChannel <- i
    }
    fmt.Println("Ended")
}

What this code does is simply run a function in a separate goroutine that keeps printing the values in a channel of buffer length 1, while the main goroutine attempts to write 1,2 into the buffer.

The snapshot of the channel at the breakpoint shows what goes on in the channel during this whole mini operation.

channel snapshot

So what happens is, at the point when the Println goroutine begins executing, the main goroutine has been placed on the sendq and the buffer has a single element (as shown by qcount) since it can only hold a single item, the integer 1.

What happened to the second number (2) that was to be passed? When goroutines try to write to a channel whose buffer is already at capacity, then it is automatically placed on the sendq until space has been made on the buffer or if there is a receiving goroutine available (unbuffered channels) to receive the value.

- sending data conclusion.

When sending data to a channel, the data is pushed into the channel's value buffer if the buffer has not been completely filled up. If it has, the goroutine trying to send data is blocked and pushed into the sending queue until it is ready to be read. In the case of an unbuffered channel (buffer size:0), the goroutine is blocked until a receiving goroutine asks for the value

How does receive work?

Again let's take a look at a sample program:

func main() {
    numberChannel := make(chan int,1)
    go func(){
        // break point applied here
        numberChannel <- 1
    }()
    // send '1' into the channel
    fmt.Println(<-numberChannel)
}

The snapshot of the numberChannel at the break point can be seen as: channel snapshot

From this, you can see sendq, in this case, is empty whereas the recvq is not empty, and there are zero items in the buffer.

- explanation/conclusion

At the break-point, the main goroutine has tried to read from the channel, which has an empty buffer. It is then placed on the recvq until data is available in the buffer for it to read from (as long as all other goroutines in the queue before it has been fed data)

Closing a channel.

When a channel is closed, data can not be sent into the channel. If you try to send data to a closed channel the app will panic and crash. Whereas, if a goroutine tries to read from a closed channel, it will not block and would always receive the zero value of the channel's data type. e.g

...
numberChannel := make(chan int,1)
close(numberChannel)
// will print 0
fmt.Println(<-numberChannel)

How is data transferred in a channel?

Data is passed in a channel by copying at least once.

For instance, if a goroutine sends data to the value buffer, a copying operation has been done. Data is also copied when it is being read from the value buffer into another goroutine. On the other hand, if data is sent/received without a buffer (unbuffered channels) then it is only copied once.

Now that we have cleared that, it is important to note that if your channel sends large/complex data types, it is often better to send pointers of the data type in the channel. You may be wondering if this is safe since the garbage collector should collect the variables being pointed to once they fall out of scope. The short answer is no, the longer answer is this stackoverflow question.

Select statement

select statements are like switches for blocking operations. The select statement is a relatively easy yet extremely powerful statement as it allows the program to run a block of code from a set of cases based on when certain operations are performed. Confused, let's take an example

func selectExample() {
    ....
    select {
    case <-channel1:
        //do stuff
    case <-channel2:
        //do this one instead
    }
}

What this whole thing does is that it waits for one of the case to become non blocking (i.e data is received) then runs the block under the case.

If all cases are nonblocking, then it randomly runs one case. If all cases are blocking, the entire select statement blocks the goroutine until one of the cases become nonblocking.

The select statement also supports a default case, the case is run if all of the other cases are blocking. This can be useful if you want to get your code to listen for cancellations before proceeding with a task e.g:

func selectExample(cancelChann) {
    select {
    case <-cancelChann:
        // clean up and cancel 
    default:
        // it wasn't cancelled go ahead and start the party
    }
}

More uses of a channel.

Now that you have a better understanding of some of the inner workings of a channel, here are some situations where channels come in handy.

- Gracefully shutting down a server

You have a server running, but you want it to take a log dump when it is being forced to shut down by the host system. How can you get this done?

func main() {
    fmt.Println("Started....")

    // listen to end
    sigChan := make(chan os.Signal)
    // subscribe to interrupt signal
    signal.Notify(c, os.Interrupt)
    go func() {
        select {
        case <-sigChan:
            // take snapshot, run cleanup, throw a party. Then exit
            ...
            os.Exit(1)
        }
    }()

    // mimick blocking statement for running server
    time.Sleep(time.Hour)
}

- Sending done/cancellation signals

Let's take this code for example:

fmt.Println("started....")
    // make cancel channel
    cancel := make(chan struct{})

    // fire function as a goroutine
    go func() {
        timer := time.NewTimer(time.Minute)
        // wait for either cancel or timer completion
        select {
        case <-cancel:
            fmt.Println("canceled")
        case timeHit := <-timer.C:
            fmt.Printf("stopped at %s \n", timeHit)
        }
    }()

    // sleep for 10 seconds then call cancel
    time.Sleep(time.Second * 10)
    close(cancel)
    fmt.Println("ended...")

We use a select statement in the goroutine to listen for the first to send a response between the cancel channel and the timer channel. Then we deliberately call the close the cancel channel early (remember closing a channel, sends nil value to all receivers) so our desired output will be printed. The output of the above code should be:

A context can also be used for something like this, but it is out of the scope of this article although it also uses a Done channel.

started...
canceled
ended...

Conclusion

From this, you can see that although channels were made perfectly to organize goroutines and communicate effectively. It's easy to not use them properly and have bugs e.g dangling channels.

You could go ahead and try to dissect channels even further and leave a comment below if you do find something worth sharing.

Regards!!