These simple go tests check the “leaky-ness” of using channels in Go. There are two methods described here; one using both a local context, and the parent context. When tests are run against both, the LeakyAsync method runs faster, but fails the leak checker as goroutines are not resolved or cleaned up.

In a production system with possibly thousands of go routines being spun up, this could result in massive memory leaks and a deadlock situation in the go binary.

it’s recommended to use the leakchecker library to determine if goroutines get cleaned up.

package perform

import (
	"context"
	"math"
)

func Selecting(parent chan struct{}) {
	i := 0
	for {
		// Selecting within the infinite loop provides
		// control from the parent chan.
		// If the parent closes, we we can exit the loop and do any cleanup
		select {
		case <-parent:
			return
		default:
		}

		i++

		if i == math.MaxInt32 {
			// Simulate an error that exits the process loop
			break
		}
	}
}

func LeakyAsync(parent chan struct{}) {
	// Start a go routine to read and block off the parent chan.
	// If the parent chan closes, we can clean up within the go routine
	// without having to perform a "select" on each iteration
	// However, this go routine will never be garbage collected
	// if the parent chan does not close and any subsequent cleanup will
	// be left to leak
	go func(c <-chan struct{}) {
		<-c
	}(parent)

	i := 0
	for {
		i++

		if i == math.MaxInt32 {
			// Simulate an error that exits the process loop
			break
		}
	}
}

func ContextAsync(parentCtx context.Context) {
	// Generate a child context from a passed in parent context.
	// If the parent is closed or canceled,
	// the child will also be closed.
	// We can then safely start a go routine that will block on the
	// child's Done channel yet will still continue if the parent is canceled.
	ctx, cancel := context.WithCancel(parentCtx)
	defer cancel()

	go func(ctx context.Context) {
		<-ctx.Done()
	}(ctx)

	i := 0
	for {
		i++

		if i == math.MaxInt32 {
			// Simulate an error that exits the process loop
			break
		}
	}
}
package perform

import (
	"context"
	"testing"

	"github.com/fortytw2/leaktest"
)

func TestSelecting(t *testing.T) {
	leakChecker := leaktest.Check(t)

	c := make(chan struct{}, 1)
	Selecting(c)

	leakChecker()
	c <- struct{}{}
}

func BenchmarkSelecting(b *testing.B) {
	for n := 0; n < b.N; n++ {
		c := make(chan struct{})
		Selecting(c)
	}
}

func TestLeakyAsync(t *testing.T) {
	leakChecker := leaktest.Check(t)

	c := make(chan struct{}, 1)
	LeakyAsync(c)

	leakChecker()
	c <- struct{}{}
}

func BenchmarkLeakyAsync(b *testing.B) {
	for n := 0; n < b.N; n++ {
		c := make(chan struct{})
		LeakyAsync(c)
	}
}

func TestContextAsync(t *testing.T) {
	leakChecker := leaktest.Check(t)

	ctx, cancel := context.WithCancel(context.Background())
	ContextAsync(ctx)

	leakChecker()
	cancel()
}

func BenchmarkContextAsync(b *testing.B) {
	for n := 0; n < b.N; n++ {
		ctx, _ := context.WithCancel(context.Background())
		ContextAsync(ctx)
	}
}

Run the test suite with the leakchecker library

❯ go test -v
=== RUN   TestSelecting
done checking leak
--- PASS: TestSelecting (11.30s)
=== RUN   TestLeakyAsync
    TestLeakyAsync: leaktest.go:132: leaktest: timed out checking goroutines
    TestLeakyAsync: leaktest.go:150: leaktest: leaked goroutine: goroutine 25 [chan receive]:
        perform.LeakyAsync.func1(0xc00008c1e0)
        	/Users/jmcbride/workspace/channels-testing/perform.go:37 +0x34
        created by perform.LeakyAsync
        	/Users/jmcbride/workspace/channels-testing/perform.go:36 +0x3f
--- FAIL: TestLeakyAsync (5.57s)
=== RUN   TestContextAsync
--- PASS: TestContextAsync (0.57s)

Run the benchmarks with bench and benchmem to see performance

❯ go test -v -bench=.  -benchmem -run "Bench*"
goos: darwin
goarch: amd64
pkg: perform
BenchmarkSelecting
BenchmarkSelecting-8      	       1	10114375732 ns/op	     104 B/op	       2 allocs/op
BenchmarkLeakyAsync
BenchmarkLeakyAsync-8     	       2	 585489776 ns/op	     704 B/op	       3 allocs/op
BenchmarkContextAsync
BenchmarkContextAsync-8   	       2	 570398894 ns/op	     976 B/op	       9 allocs/op
PASS
ok  	perform	13.655s

LeakyAsync is roughly 2 times faster. But fails the leak checker test as the goroutine is not resolved.

Selecting is slow because it performs a select on every iteration of the for loop.

ContextAsync is the best of both worlds. We don’t have to do a select within the for loop, yet we avoid a go routine leak.


If you found this blog post valuable, consider subscribing to future posts via RSS or buying me a coffee via GitHub sponsors.