DEV Community

Cover image for signal.NotifyContext in Go: Clean Ctrl-C Handling in One Line
Gabriel Anhaia
Gabriel Anhaia

Posted on

signal.NotifyContext in Go: Clean Ctrl-C Handling in One Line


You've written this block. Every Go service has one. A signal.Notify
call, a channel of os.Signal, a goroutine that blocks on the
channel and then flips a switch somewhere to start shutdown. It's the
part of main that everyone copies from the last service and nobody
reads.

func main() {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        <-sigCh
        // ...now what? set a flag? close a channel?
        // call server.Shutdown from in here?
    }()

    // ...run the server
}
Enter fullscreen mode Exit fullscreen mode

The // now what is where the bugs live. Someone closes a shared
channel from the signal goroutine while another goroutine is still
sending on it. Someone forgets to signal.Stop and the handler
outlives the thing it was supposed to stop. Someone reaches for a
package-level var shutdown bool and reads it without a lock.

The standard library has shipped the answer since Go 1.16, and most
codebases still haven't adopted it. It's signal.NotifyContext, and
it turns the whole block above into one line.

What NotifyContext actually does

signal.NotifyContext takes a parent context and a list of signals.
It returns a derived context and a stop function. When any of those
signals arrives, the returned context is cancelled — its Done()
channel closes, exactly like a WithCancel context whose cancel
you never had to call yourself.

ctx, stop := signal.NotifyContext(
    context.Background(),
    syscall.SIGINT, syscall.SIGTERM,
)
defer stop()
Enter fullscreen mode Exit fullscreen mode

That's the replacement for the channel, the Notify call, and the
signal goroutine. Ctrl-C is now just another way for ctx to get
cancelled. Everything downstream that already respects ctx.Done()
gets the shutdown signal for free: your HTTP handlers, your database
calls, your worker loops all use the same plumbing they already lean
on for request cancellation and timeouts.

The stop function does two things. It restores the default signal
behaviour (so a signal after stop() kills the process the normal
way), and it releases the resources the notifier holds. defer stop()
in main covers both. This is the piece hand-rolled versions forget:
without a signal.Stop, the runtime keeps delivering signals to a
handler that's done caring.

Wiring it into an HTTP server

Here's the shape that matters, an http.Server that drains
in-flight requests when you press Ctrl-C.

func main() {
    ctx, stop := signal.NotifyContext(
        context.Background(),
        syscall.SIGINT, syscall.SIGTERM,
    )
    defer stop()

    srv := &http.Server{Addr: ":8080", Handler: routes()}

    go func() {
        err := srv.ListenAndServe()
        if err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    <-ctx.Done()
    log.Println("shutting down")

    shutCtx, cancel := context.WithTimeout(
        context.Background(), 10*time.Second,
    )
    defer cancel()

    if err := srv.Shutdown(shutCtx); err != nil {
        log.Printf("graceful shutdown failed: %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Read the control flow. main blocks on <-ctx.Done(). The server
runs in its own goroutine. When SIGINT lands, ctx cancels, the
blocking receive returns, and you call srv.Shutdown with a fresh
timeout context.

That last detail trips people up. The shutdown context must be a
new context, not the one that just got cancelled. srv.Shutdown
waits for in-flight requests to finish, and it stops waiting when its
context is done. If you passed the already-cancelled ctx, Shutdown
would return instantly and drop every open connection — the opposite
of graceful. So you derive shutCtx from context.Background() with
a deadline: drain for up to ten seconds, then give up.

The second-signal problem

Graceful shutdown has a failure mode. Suppose a request is wedged —
a slow upstream, a stuck query, a deadlock in your own code. Your
ten-second drain window starts, and the operator watching the
terminal wants out now. They press Ctrl-C again.

With the code above, nothing happens. The second SIGINT is delivered
to the context notifier, but that context is already cancelled, so
there's no new effect. The operator holds Ctrl-C, gets impatient, and
reaches for kill -9. Now you've lost the clean exit you built and
whatever the process was mid-write on.

The fix is to make the second signal force-quit. After the first
signal cancels ctx, call stop() to hand signal handling back to
the runtime. From that point a second Ctrl-C terminates the process
the default way.

<-ctx.Done()
log.Println("shutting down, press Ctrl-C again to force")
stop() // second signal now kills the process
Enter fullscreen mode Exit fullscreen mode

Calling stop() early restores default behaviour immediately. The
first signal did the graceful thing. The second signal does the
brutal thing. That two-stage exit is what interactive tools like
kubectl, docker, and most well-behaved servers give you, and now
yours does too.

If you'd rather force-quit on your own terms — say, log a reason
first — spin a small watcher instead of calling stop():

<-ctx.Done()
stop()
log.Println("draining; Ctrl-C again to force")

forceCtx, _ := signal.NotifyContext(
    context.Background(),
    syscall.SIGINT, syscall.SIGTERM,
)

go func() {
    <-forceCtx.Done()
    log.Println("forced")
    os.Exit(1)
}()
Enter fullscreen mode Exit fullscreen mode

The first stop() releases the original notifier; the second
NotifyContext starts listening again for the force-quit. Most
services don't need this much ceremony. Reach for it only when you
want to log or emit a metric on the forced path.

Passing the context down, not a bool

The real payoff isn't the shorter main. It's that shutdown now
travels the same road as every other cancellation in your program.

A worker pool that already takes a context needs no signal-specific
code at all:

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case j, ok := <-jobs:
            if !ok {
                return
            }
            handle(ctx, j)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pass the ctx from NotifyContext into these workers and Ctrl-C
drains them the same way a deadline would. No package-level flag, no
second channel, no mutex guarding a bool that three goroutines read.
The signal became a context cancellation at the top of main, and
from there it's just context all the way down — the mechanism your
codebase already understands.

That's the argument for signal.NotifyContext over the channel
boilerplate. Not that it's fewer lines, though it is. It's that it
collapses a special case into the general one. Signals stop being
their own subsystem and become one more reason a context is done.

What to change on Monday

  • Find the signal.Notify(...) in your main. Replace the channel, the Notify call, and the signal goroutine with one signal.NotifyContext and a defer stop().
  • Make sure srv.Shutdown gets a fresh timeout context, not the cancelled one.
  • Add the stop()-after-Done() line so a second Ctrl-C force-quits instead of hanging.
  • Delete any package-level shutdown bool. If a goroutine needs to know you're stopping, give it the context.

The signal-channel pattern was the right answer in 2015. Since Go
1.16 it's been a habit, not a requirement.


If this was useful

Signal handling is one of those corners where the idiomatic answer
changed and the muscle memory didn't. The Complete Guide to Go
Programming
walks through context, the os/signal package, and
how the runtime turns a SIGINT into a cancelled Done() channel —
the runtime-level view of what NotifyContext is doing for you.
Hexagonal Architecture in Go is about keeping this at the right
boundary, so signal handling stays in main and your domain code
only ever sees a plain context.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)