The Go programming language has a pair of features that work well together for assembling a sequence of steps into a pipeline: goroutines and channels. In order to use these successfully, the goroutine needs to listen for communication it expects and watch for communication that might happen. 

We expect a channel to feed the goroutine items and be closed when no more items are forthcoming. Meanwhile, we need to watch a Context in case the goroutine should exit before the channel is closed. This article will focus on handling some of the edge cases that will keep your goroutines from finishing.

Goroutines communicating over a channel
Goroutines communicating over a channel

There are some fundamental things you should understand before using channels and goroutines. Start by completing the section of the tour of concurrency. With an understanding of the fundamentals, we can explore the details of goroutines communicating over a channel.

The goroutine functions will each be responsible for detecting when it's time to finish:

  • It is important to check in with your context regularly to see if it is "Done."
  • A closed channel will give a waiting receiver the zero value.
  • Ranging on channel loops until that channel is closed.

Let's take a close look at two of the more common approaches I've seen. There's a lot to learn by looking at the trade-offs between these two approaches. 

Infinite for loop

func ForInfinity(ctx context.Context, inputChan chan string) func() error {
	return func() error {
		for {
			select {
			case input := <-inputChan:
				if len(input) == 0 {
					return ctx.Err()
				}
				fmt.Println("logic to handle input ", input)
			case <-ctx.Done():
				return ctx.Err()
			}
		}
	}
}
  • When the inputChan channel is closed, you have to look for the zero value on the channel. The logic of that select case will need to detect the zero value to finish the goroutine – perhaps with a return nil or return ctx.Err().
  • When the context done case is selected, it feels natural to return ctx.Err(). By doing so, the goroutine is reporting the underlying condition that caused it to finish. Depending on the type of context and its state, the ctx.Err() may be nil.
  • If more than one select case is ready then one will be chosen at random. Given the undefined nature of having both of these case statements ready, you might consider having the zero-value-detecting logic return ctx.Err(). This will ensure your goroutine returns as accurately as possible, even if the channel case was selected.

Range on the channel

func ForRangeChannel(ctx context.Context, inputChan chan string) func() error {
  return func() error {
    for input := range inputChan { 
      select {
      case <-ctx.Done():
        return ctx.Err()
      default:
        fmt.Println("logic to handle input ", input)
      }
    }
    return nil
  }
}
  • While the goroutine is waiting to receive on inputChan, it will not exit unless the channel is closed. Now our pipeline func is dependent on the channel close. If the Context is "Done," we won't know it until an item is received from the range inputChan. Upstream pipeline functions should close their stream when finishing.
  • Range won't give us the zero-value-infinite-loop, as in the earlier example. The Range will drop out to our final return nil when the channel is closed.
  • The context Done case has the same impact here as it did in the earlier example. The difference here is that the Done context will not be discovered until the channel receive occurs — making it even more important that the channels are closed.

Be mindful of the flow inside your goroutine to ensure it finishes appropriately. That and lots of tests will ensure your goroutines under normal and exceptional scenarios. Here are a couple of tests to get you started. These are written to exercise the same scenarios for each of the above goroutines.

func TestForInfinity(t *testing.T) {
	t.Run("context is canceled", func(t *testing.T) {
		inputChan := make(chan string)

		ctx, cancel := context.WithCancel(context.Background())
		cancel()
		f := ForInfinity(ctx, inputChan)
		err := f()

		assert.EqualError(t, err, "context canceled")
	})
	t.Run("closed channel returns without processing", func(t *testing.T) {
		inputChan := make(chan string)
		close(inputChan)

		ctx := context.Background()
		f := ForInfinity(ctx, inputChan)
		err := f()

		assert.NoError(t, err, "closed chanel return nil from ctx.Err()")
	})
}
func TestForRangeChannel(t *testing.T) {
	t.Run("context is canceled", func(t *testing.T) {
		inputChan := make(chan string)

		ctx, cancel := context.WithCancel(context.Background())
		cancel()
		f := ForRangeChannel(ctx, inputChan)
		go func() {
			//this test will hang without this goroutine
			<-time.After(time.Second)
			inputChan <- "some value"
		}()
		err := f()

		assert.EqualError(t, err, "context canceled")
	})
	t.Run("closed channel returns without processing", func(t *testing.T) {
		inputChan := make(chan string)
		close(inputChan)

		f := ForRangeChannel(context.Background(), inputChan)
		err := f()

		assert.NoError(t, err, "note there is no need to cancel the context, 'range' ends for us")
	})
}

Summary

By understanding how channels interact with range and select you can ensure your goroutine exits when it should. We have used both examples above successfully. Each different design has trade-offs. No matter the logic flow in your goroutines, always ask yourself: "how will this exit?" Then test it.

I think it's time for another Go Proverb: Never start a goroutine you can't finish!

Technologies