This is the third article in the Golang Concurrency Series and in this part we are going to take a closer look at how select and for range work with Go channels. This guide highlights less-known behaviors and practical patterns, deepening your understanding of Go concurrency.

All Articles of the Series :
1. Golang Concurrency : Goroutines

2. Golang Concurrency : Channels

3. Golang Concurrency: Select and For Range Channel

In our previous discussion we have covered the basics and fundamentals of goroutines and channels , if you have not read them , I would seriously recommend to read them before coming to this article.

Now let us focus on select and for range in Go channels.

Select

Basically, the select statement provides a mechanism for a goroutine to wait on multiple channels using the case statement and its main job is to execute the first case that is ready.

select {
  case msg1 := <-chan1:
    fmt.Println("Received from chan1", msg1)
  case msg2 := <-chan2:
    fmt.Println("Received from chan2", msg2)
}

1. How it works?

In the above statement, the select block waits for either chan1 or chan2 to deliver data. Whenever any of those channels send data the corresponding case executes. It executes one of them, whichever channels sends the data first, the corresponding statement is executed.

What happens when both of them send data simultaneously?

Well in this case, there is no set rule, the select statement does not prioritize any case over the other, rather, it randomly chooses one of the cases that is ready and executes it.

What happens when there is a default case?

Well if a default case is included in the select statement and none of the channels are ready, then the select does not wait, it quickly executes the default case:

select {
  case msg1 := <-ch1:
    fmt.Println("Received", msg1)
  case msg2 := <-ch2:
    fmt.Println("Received", msg2)
  default:
    fmt.Println("No activity")
}

// Output: No activity

2. Handling closed channels

What happens when one of the channels that select is listening closes?

From our earlier articles, we already know that: “sending data to a closed channel will cause a panic, while receiving data from a closed channel will return the zero value of the channel’s type if no values remain”.

Let us see an example:

chan1 := make(chan int)
close(chan1)

select {
  case msg1 := <-chan1:
    fmt.Println("Received from chan1", msg1)  // This line will execute.
  case msg2 := <-chan2:
    fmt.Println("Received from chan2", msg2)
}

After we close the chan1 , the line case msg1 := <-chan1: is executed immediately returning and printing the zero value of integer type (0).

But here is one more question, how do we know that 0 was actually delivered by the channel, or we received it because the channel was closed? A lot of business decisions can be dependent on this, and hence we need to know what exactly happened. Let us refine our approach, making it more verbose:

select {
  case msg1, ok := <-chan1:
    if ok {
      fmt.Println("Received from chan1", msg1)
    } else {
      fmt.Println("Channel closed!")
    }
  case msg2 := <-chan2:
    fmt.Println("Received from chan2", msg2)
}

And here it is, we are making use of the boolean variable ok to check if the channel is closed or not and handling the cases accordingly.

3. Timeout Trap

Suppose we want to wait for a channel for only for a couple of seconds and then move to the next parts of the code, the select statement in Go makes it really easy.

You can use of time.After to make sure the operation doesn’t get stuck.  This function lets you move on after waiting for a certain amount of time, instead of waiting forever for something to happen:

select {
  case msg1 := <-chan1:
    fmt.Println("Received from chan1", msg1)
  case <-time.After(2 * time.Second):
    fmt.Println("Timeout")
}

In this block, if chan1 does not return anything within 2 seconds, the second case activates and the select statement returns.

What happens when chan1 actually delivers some data before 2 seconds?

In this case, as expected the corresponding case runs and fmt.Println("Received from chan1", msg1) is executed and the select statement exits. However there is a catch, the timer keeps running in the background, and upon completion it does send a value it’s channel, however since we are not listening to it, nothing visible happens to the code.

This might not seem problematic at first, but it can affect memory usage since this value stays in memory until its timer runs out.

To handle this more efficiently, it’s recommended to use time.NewTimer:

timer := time.NewTimer(2 * time.Second)

select {
  case msg1 := <-chan1:
    fmt.Println("Received from chan1", msg1)
    timer.Stop() // we stop the timer
  case <-timer.C:
    fmt.Println("Timeout")
}

fmt.Println("After select statement")

By using this approach, we clear the timer when the chan1 actually sends data before the timer runs out.

But how does saving 1 second of timer matter?

Well it does not matter here because this is just an example, now just image an api or a service serving thousands of requests per second. There are now thousands of timers running in the background, serving absolutely nothing, and putting pressure on memory of the server, this is why it matters.

4. Combining for and select{}

When we use a for loop together with a select statement, we create a method for continually checking multiple channels:

for {
  select {
  case msg1 := <-chan1:
    fmt.Println("Received from chan1:", msg1)
  case msg2 := <-chan2:
    fmt.Println("Received from chan2:", msg2)
  case <-time.After(1 * time.Second):
    fmt.Println("Waiting...")
  }
}

Here, our application enters a never-ending for loop, and within this loop, the select statement is waiting on several things at once: the channels chan1 and chan2, and also a timer.

However, there’s an issue if one of the channels, like `chan1`, is closed, this closed channel stops being a blockage and starts sending endless default values, cluttering the output with repetitive messages:

Received from chan1: 0
Received from chan1: 0
Received from chan1: 0
Received from chan1: 0
…

To mitigate this, we use an approach where we check the status of the received message, and if ok is false, it means the channel has closed.

for {
  select {
    case msg1, ok := <-chan1:
      if !ok {
        fmt.Println("chan1 is closed. Exiting loop.")
        break
      }
      fmt.Println("Received from chan1:", msg1)
    case msg2, ok := <-chan2:
      if !ok {
        fmt.Println("chan2 is closed. Exiting loop.")
        break
      }
      fmt.Println("Received from chan2:", msg2)
    case <-time.After(1 * time.Second):
      fmt.Println("Waiting...")
    }
}

Will it work as expected?

Unfortunately, it doesn’t. The break here doesn’t quit the for loop, it only exits the select, and the loop goes on without end.

5. Label

Go language allows loops to have labels, we can use these labels to tell the break statement what to break from.

Here is the coding example:

Loop:
  for {
    select {
      case msg1, ok := <-chan1:
        if !ok {
          fmt.Println("chan1 is closed. Exiting loop.")
          break Loop // This exits the loop named Loop
        }
        fmt.Println("Received from chan1:", msg1)
      case msg2, ok := <-chan2:
        if !ok {
          fmt.Println("chan2 is closed. Exiting loop.")
          break Loop // Same here
        }
        fmt.Println("Received from ch2:", msg2)
      case <-time.After(1 * time.Second):
        fmt.Println("Waiting...")
      }
  }

Here, Loop is the label.

The break Loop statement tells the application to exit the Loop when it runs and this label helps in directing the flow precisely, avoiding any unintended continuations.

For Range

While listening to a channel, the for range in Golang is very handy. Instead of repeatedly checking if there’s a new value in the channel, now you can just let the loop handle it.

Uniquely, the for range used with a channel yields only one value, different from other iterations (like for k, v := range m):

chan1 := make(chan int)

go func() {
  for i := 0; i < 10; i++ {
    chan1 <- i
  }

  close(chan1) // Don't forget this step!
}()

for value := range chan1 {
  fmt.Println("Received:", value)
}

The loop for value := range chan1 will keep pulling data from the chan1 channel until you close it.

There is a common trap, if you forget to close the channel, the loop will just keep waiting and it’ll be stuck, waiting for new values forever (deadlock). So, if you’re running a bunch of goroutines, this mistake can eat up memory pretty fast.

So there were some of the usages, common errors and patterns to use the select & for range statement with channnels in go. I hope you are enjoying this series on golang and concurreny. Please feel free to reach out to me on Twitter or LinkedIn.


Will be back with next article of the series soon.


0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

Wordpress Social Share Plugin powered by Ultimatelysocial
error

Enjoy this blog? Please spread the word :)