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 areturn nil
orreturn 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, thectx.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 a 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 therange 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!