Respond to Ctrl+C interrupt signals gracefully.

Mat Ryer · 17 Feb 2020

Respond to Ctrl+C interrupt signals gracefully.

Mat Ryer · 17 Feb 2020

Scroll down to start reading

The problem

When our Go programs are running, if someone sends an interrupt signal (when they hit Ctrl+C), it kills our program immediately.

Sometimes we might like to have finished what we were doing, or tidy up some resources before exiting.

We can acheive this in Go using a mix of the os/signal and context packages.

Critics of this approach say that context should be reserved for request/response things. I think this is a request and response situation. You make a request with a command and arguments in a terminal, and you wait for a response. So interrupting your tool with context fits perfectly with the pattern of using context for cancellation in an HTTP world.

Tutorial: Catching signals and gracefully shutting down

We will create a new context that we will use throughout our tool. We’ll then trap the interrupt signals, and when we receive the first one we will cancel the context.

Once the context has been cancelled, we’ll rely on the natural unwinding of the program to lead to the exit of the main function, and therefore the end of the program.

If meanwhile we recieve another signal (a second signal), then we will abort immediately via os.Exit.

  • Scroll down for the full code

Context with cancel

We’ll start by creating a cancallable context.

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

Normally here you would defer the call to cancel() so it frees up any resources used, but we’ll do that later.

Trapping signals (like Ctrl+C)

Next we’ll make a channel to catch the interrupt signal from the operating system:

signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt)

Now we will defer not only the call to the context’s cancel() function, but also the Stop call that accompanies signal.Notify:

defer func() {
	signal.Stop(signalChan)
	cancel()
}()

Wiring signals up to context

Now we are going to kick off a background goroutine that waits for either the context to be cancelled (via the deferred call we just added), or for a signal to come through our signalChan channel. We’ll achieve this with a select block:

go func() {
	select {
	case <-signalChan: // first signal, cancel context
		cancel()
	case <-ctx.Done():
	}
	<-signalChan // second signal, hard exit
	os.Exit(exitCodeInterrupt)
}()

The select will block until the context is done, or we get a signal. If we get a signal (case <-signalChan) then we call cancel(); this is how we cancel the context when the user presses Ctrl+C.

Execution then falls to where we are just waiting for another signal <-signalChan. If it receives anything on the signalChan channel (a second signal), it calls os.Exit which exits without waiting for the program to naturally finish.

If the program naturally exits after the first signal but before the second, this goroutine will be killed when the program exits (when the main function returns) so there’s no need to go to any extra effort to clean it up, however dissatisfying that might feel.

Pass the context to your program

Finally, we are going to call out to a run function that is going to do the heavy lifting of our program, passing in the context.Context (which it will respect), and any dependencies from the operating system.

if err := run(ctx, os.Args); err != nil {
	fmt.Fprintf(os.Stderr, "%s\n", err)
	os.Exit(exitCodeErr)
}

This gives us the foundation for programs that behave nicely for developers and orchestration systems alike, with graceful shutdown and tidy-up.

The complete code

package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
)

const (
	exitCodeErr       = 1
	exitCodeInterrupt = 2
)

func main() {
	ctx := context.Background()
	ctx, cancel := context.WithCancel(ctx)
	signalChan := make(chan os.Signal, 1)
	signal.Notify(signalChan, os.Interrupt)
	defer func() {
		signal.Stop(signalChan)
		cancel()
	}()
	go func() {
		select {
		case <-signalChan: // first signal, cancel context
			cancel()
		case <-ctx.Done():
		}
		<-signalChan // second signal, hard exit
		os.Exit(exitCodeInterrupt)
	}()
	if err := run(ctx, os.Args); err != nil {
		fmt.Fprintf(os.Stderr, "%s\n", err)
		os.Exit(exitCodeErr)
	}
}

func run(ctx context.Context, args []string) error {
	for {
		select {
		case <-ctx.Done():
			return nil
		default:
			// do a piece of work
		}
	}
}

By the way, we're building something...

A lot of our blog posts come out of the technical work behind a new project we're working on called Pace.

We were frustrated by project management tools that interrupt you all day. Overly complicated workflows made simple tasks hard. So we decided to build Pace.

Pace is a new minimalist project management tool for tech teams. We promote asyncronous communication by default, while allowing for those times when you really need to chat.

We shift the way work is assigned by allowing only self-assignment, creating a more empowered team and protecting the attention and focus of devs.

We're building it right now, and would love to hear from you especially if you've got strong opinions on what project management tools should and shouldn't do.

What next? Learn more about Pace and say hello


First published on 17 Feb 2020 by Mat Ryer
#Golang #Patterns

or you can share the URL directly:

https://pace.dev/blog/2020/02/17/repond-to-ctrl-c-interrupt-signals-gracefully-with-context-in-golang-by-mat-ryer.html

Thank you, we don't do ads so we rely on you to spread the word.

https://pace.dev/blog/2020/02/17/repond-to-ctrl-c-interrupt-signals-gracefully-with-context-in-golang-by-mat-ryer.html


Other stories

Why you shouldn't use func main in Go #Golang #Patterns

Testing unlisted blog posts #Golang #Patterns

Context-aware io.Reader for Go #Tech #Golang

Subscribe

Atom RSS JSON