Killing Me Softly, Or: How Goroutines Want To Exit

If you have worked with POSIX threads in the past, or with threading libraries in various languages, you might have been surprised by the completely different approach to managing concurrency.

The Ultimate Guide to Debugging With Go
Learn debugging with Matt, at 40% off
(and support AppliedGo this way)!
Use the coupon code APPLIEDGO40 at ByteSizeGo.com.
(Affiliate Link)

POSIX threads, or system threads, come with a big dashboard full of control knobs, or rather, a set of control functions such as pthread_atfork(3), pthread_attr_init(3), pthread_cancel(3), pthread_cleanup_push(3), pthread_cond_signal(3), pthread_cond_wait(3), pthread_create(3), pthread_detach(3), pthread_equal(3), pthread_exit(3), pthread_key_create(3), pthread_kill(3), pthread_mutex_lock(3), pthread_mutex_unlock(3), pthread_mutexattr_destroy(3), pthread_mutexattr_init(3), pthread_once(3), pthread_spin_init(3), pthread_spin_lock(3), pthread_rwlockattr_setkind_np(3), pthread_setcancelstate(3), pthread_setcanceltype(3), pthread_setspecific(3), pthread_sigmask(3), pthread_sigqueue(3), or pthread_testcancel(3).

Goroutines, on the other hand, have only one “knob”:

The go directive.

No create, cancel, cancel states, detach, fork handling, thread variable creation magic, or kill mechanism.

Especially, no kill mechanism.

Which raises one or another eyebrow along with the question, “but how can I stop a goroutine from the outside?”

How to stop a goroutine from the outside

Short answer: you can't.

Long answer: You can't, and you shouldn't, and you can instead politely ask the goroutine to exit.

You can't, because there's no kill switch.

You shouldn't, as killing concurrently executing code from the outside can have unwanted side effects on open resources or other parts of the program that are in exchange with that concurrent code.

How to ask a goroutine to exit

However, you can politely ask the goroutine to exit, and you even have several easy ways to do that:

  1. By closing a channel that the goroutine is listening to.
  2. By sending a value to a channel that the goroutine is listening to.
  3. By passing a context.Context to the goroutine and calling context.CancelFunc from the outside.

Exiting through channel communication

The first two options are suitable for simple scenarios, especially if you have no Context to pass around.

Here is how to have a goroutine exit by closing a channel:

package main
import (
	"fmt"
	"time"
)
func main() {
	quit := make(chan struct{})
	go func() {
		for {
			select {
			case <-quit:
				fmt.Println("goroutine exiting")
				return
			default:
				fmt.Println("goroutine working")
				time.Sleep(1 * time.Second)
			}
		}
	}()
	time.Sleep(3 * time.Second)
	fmt.Println("asking to exit")
	close(quit)
	time.Sleep(1 * time.Second)
}

(Playground link)

The case <-quit case will be selected when the quit channel is closed, because once a channel is closed, the readers start recieving the zero value of the channel's type immediately.

Note that it's the goroutine's responsibility to exit when the request arrives. Within the <- quit case, the goroutine can do necessary cleanup such as closing resources or returning them to a resource pool.

The second option is similar, but instead of closing the channel, you send a value to it:

	quit <- struct{}{}

(Put this in place of close(quit) above.)

Exiting through context.Context

The third option is more flexible and is the standard way to exit goroutines that use a Context.

A quick refresher: context.Context is an interface that carries deadlines, cancellation signals, and request-scoped values across API boundaries and between processes.

To exit a goroutine using a Context, you create a WithCancel context that has a Done channel and provides a cancel function. By calling the cancel function, you signal the goroutine to exit.

Here's an example:

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		for {
			select {
			case <-ctx.Done():
				fmt.Println("goroutine exiting")
				return
			default:
				fmt.Println("goroutine working")
				time.Sleep(1 * time.Second)
			}
		}
	}()
	time.Sleep(3 * time.Second)
	fmt.Println("cancelling")
	cancel()
	time.Sleep(1 * time.Second)
}

(Playground link)

Goroutines are a simple concurrency mechanism

Goroutines are ideally suited for one-time concurrent work. They are extremely lightweight and have a low starting overhead. There is no need to “remote-control” goroutines to start, stop, pause, or resume them. If the work you want to implement concurrently is so complex that you need such mechanisms, forking or calling a new process is probably an approach worth considering. Or you simplify the concurrent work enough so that you won't need any sophisticated goroutine runtime management.