Jul

10

Closing Channels Twice in Go

Concurrency is natural with the Go language's channels and goroutines. There do exist a few gotchas, however. This post is going to focus on just one: how can we avoid closing channels twice?

The problem lies with the fact that the Go runtime will panic if you close a channel twice. The most minimal example:

package main

func main() {
	ch := make(chan struct{}, 0)
	close(ch)
	close(ch)
	// Output:
	// panic: close of closed channel
	//
	// goroutine 1 [running]:
	// main.main()
	//	/tmp/sandbox701291283/main.go:6 +0x80
}

What are some scenarios this might occur? Why would we ever close a channel twice? Is there a simple way to avoid closing twice? Let's find out.

Channels as a signal

One common scenario is using channels to signal across goroutines that they need to shutdown. One might imagine:

package main

import (
	"fmt"
	"time"
)

// fn is some function.
type fn func()

// scheduler is a minimal task queue.
type scheduler struct {
	queue chan fn

	quit chan struct{}
	done chan struct{}
}

// work continuously reads from the queue channel to perform work.
func (s *scheduler) work() {
	for {
		// Since this select block doesn't have a default, this
		// goroutine will block the until either s.quit closes
		// or something is written to s.queue for us to read.
		select {
		case w := <-s.queue:
			w()

		case <-s.quit:
			// Time to shutdown.
			// Close the queue and drain it.
			close(s.queue)
			for w := range s.queue {
				w()
			}
			return
		}
	}
}

// shutdown signals to the scheduler to stop all workers.
func (s *scheduler) shutdown() {
	// Closing this channel causes any subsequent read operation to
	// return the zero value immediately.
	close(s.quit)
}

func main() {
	s := &scheduler{make(chan fn, 5), make(chan struct{}, 0), make(chan struct{}, 0)}

	wk := func() {
		fmt.Println("werk")
	}

	go s.work()

	s.queue <- wk
	s.queue <- wk
	s.queue <- wk
	s.queue <- wk

	s.shutdown()
	
	time.Sleep(10 * time.Millisecond)
	// Output:
	// werk
	// werk
	// werk
	// werk
}

Play with the example above on the Go playground.

This works great with at least one drawback: calling s.shutdown() does not block-- the program immediately exits. Try removing the time.Sleep() call. The program will probably exit before the first "werk" is printed. Even though we are draining the queue, the program will exit before the queue drains.

The road goes both ways

A simple solution is to create a second channel that is used to signal back to the shutdown method that the worker goroutine is done.

package main

import (
	"fmt"
)

// fn is some function.
type fn func()

// scheduler is a minimal task queue.
type scheduler struct {
	queue chan fn

	quit chan struct{}
	done chan struct{}
}

// work continuously reads from the queue channel to perform work.
func (s *scheduler) work() {
	for {
		select {
		case w := <-s.queue:
			w()

		case <-s.quit:
			close(s.queue)
			for w := range s.queue {
				w()
			}
			// We're all done!
			close(s.done)
			return
		}
	}
}

// shutdown signals to the scheduler to stop all workers.
func (s *scheduler) shutdown() {
	close(s.quit)
	// Then, we read from s.done. This blocks until a value is written to
	// the channel for us to read, or the channel is closed. We're going
	// to rely on the latter.
	<-s.done
}

func main() {
	s := &scheduler{make(chan fn, 5), make(chan struct{}, 0), make(chan struct{}, 0)}

	wk := func() {
		fmt.Println("werk")
	}

	go s.work()

	s.queue <- wk
	s.queue <- wk
	s.queue <- wk
	s.queue <- wk

	s.shutdown()
	// Output:
	// werk
	// werk
	// werk
	// werk
}

Play with the above example on the Go playground.

This works well, but we what happens if shutdown is called multiple times? Add go s.shutdown() above the existing line in the previous example, and run it. Panic!

Closing Channels Safely

If a given channel can be closed multiple times, it must be determined if the channel is already closed. Using synchronization primitives is what some might reach for at this point, but that's completely unnecessary.

Guard channel closes with a select statement that tries to read from the channel in question before closing it.

package main

import (
	"fmt"
)

// fn is some function.
type fn func()

// scheduler is a minimal task queue.
type scheduler struct {
	queue chan fn

	quit chan struct{}
	done chan struct{}
}

// work continuously reads from the queue channel to perform work.
func (s *scheduler) work() {
	for {
		// Since this select block doesn't have a default, this
		// goroutine will block the until either s.quit closes
		// or something is written to s.queue for us to read.
		select {
		case w := <-s.queue:
			w()

		case <-s.quit:
			// Time to shutdown.
			// Close the queue and drain it.
			close(s.queue)
			for w := range s.queue {
				w()
			}
			// We're all done!
			close(s.done)
			return
		}
	}
}

// shutdown signals to the scheduler to stop all workers.
func (s *scheduler) shutdown() {
	select {
	case <-s.quit:
		// already closed, do nothing

	default:
		// Signal to workers that we are quitting
		close(s.quit)
	}
	// Then, we read from s.done. This blocks until a value is written to
	// the channel for us to read, or the channel is closed. We're going
	// to rely on the latter.
	<-s.done
}

func main() {
	s := &scheduler{make(chan fn, 5), make(chan struct{}, 0), make(chan struct{}, 0)}

	wk := func() {
		fmt.Println("werk")
	}

	go s.work()

	s.queue <- wk
	s.queue <- wk
	s.queue <- wk
	s.queue <- wk

	go s.shutdown()
	s.shutdown()
	// Output:
	// werk
	// werk
	// werk
	// werk
}

Attempting to read from a closed channel will return immediately, and since these channels are not for values, but are instead used as signals, we have no worries about who or how often someone calls shutdown.

Play with the above example on the Go playground.

A small challenge

The example above only supports a single worker goroutine; creating multiple will cause panics when each worker attempts to close(s.queue) and then close(s.done).

Try out the broken challenge on the Go playground.

How can the example be modified to support any number of worker goroutines?

Wrapping up

I hope this article helped increase your understanding of Go channels. How do you use channels?

goProgramming

Comments

No comments yet! Say something.