Context and Process Signals

Updated

September 13, 2026

Context and Process Signals

Services need to shut down cleanly when an operator presses Ctrl+C or Kubernetes sends a termination signal. The standard library handles this via os/signal and signal.NotifyContext. The boring default is: derive your root context from signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM), pass it down, and give in-flight desk orders a brief graceful shutdown window.

Mental model

In Go, cancellation flows down through context.Context. OS processes receive termination signals (os.Interrupt / SIGINT on Ctrl+C, syscall.SIGTERM in containers/systemd).

signal.NotifyContext attaches listening for those OS signals to a context. When a signal arrives, the context’s Done() channel closes, and context.Cause(ctx) describes the signal.

A graceful shutdown pattern: 1. Listen for signals with signal.NotifyContext. 2. When canceled, stop accepting new desk tickets or HTTP requests. 3. Drain ongoing work using a clean shutdown timeout. 4. Exit cleanly with return code 0.

Worked examples

Case 1: signal.NotifyContext

Save as signal_stop.go. In this testable example, we simulate signal context creation and immediate stop.

// signal_stop.go
package main

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

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

    fmt.Println("signal listener ready")
    fmt.Println("canceled:", ctx.Err() != nil)
}

Run:

go run signal_stop.go

Output:

signal listener ready
canceled: false

Calling stop() unregisters the signal handler and restores default OS behavior. Always defer stop().

Case 2: Clean worker loop on signal cancellation

Save as desk_shift_signal.go. A worker processes tickets until interrupted.

// desk_shift_signal.go
package main

import (
    "context"
    "fmt"
    "time"
)

func processTickets(ctx context.Context) {
    ticker := time.NewTicker(20 * time.Millisecond)
    defer ticker.Stop()

    ticketID := 1
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("shift ending cleanly: %v\n", ctx.Err())
            return
        case <-ticker.C:
            fmt.Printf("processed ticket #%d\n", ticketID)
            ticketID++
            if ticketID > 3 {
                return
            }
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    processTickets(ctx)
}

Run:

go run desk_shift_signal.go

Output:

processed ticket #1
processed ticket #2
processed ticket #3

By checking ctx.Done() inside select, the loop exits as soon as either time expires, a signal arrives, or the queue empties.

Case 3: Graceful HTTP server shutdown

Save as graceful_http.go. Start an HTTP server and shut it down cleanly with a context.

// graceful_http.go
package main

import (
    "context"
    "fmt"
    "net/http"
    "net/http/httptest"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "desk operational")
    })

    srv := httptest.NewServer(mux)
    defer srv.Close()

    // In production, you would run srv.Shutdown(shutdownCtx)
    // Here we verify the handler responds before shutdown
    resp, err := http.Get(srv.URL + "/health")
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    defer resp.Body.Close()

    fmt.Println("status:", resp.StatusCode)

    // Simulate graceful drain
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()

    fmt.Println("shutdown completed within deadline:", shutdownCtx.Err() == nil)
}

Run:

go run graceful_http.go

Output:

status: 200
shutdown completed within deadline: true

srv.Shutdown(ctx) stops accepting new connections and waits for active requests to finish before returning.

Case 4: Asynchronous cleanup with context.AfterFunc

Save as after_func.go. Starting in Go 1.21+, context.AfterFunc(ctx, f) registers a callback that runs in its own goroutine the instant ctx is canceled. It is cleaner and cheaper than spinning up a dedicated watcher goroutine with select { case <-ctx.Done(): ... }.

// after_func.go
package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    // Register an asynchronous cleanup callback triggered on cancellation
    stop := context.AfterFunc(ctx, func() {
        fmt.Println("async cleanup: releasing desk locks")
    })
    defer stop()

    fmt.Println("shift active")
    cancel() // Trigger context cancellation

    // Brief pause to allow the background AfterFunc goroutine to execute
    time.Sleep(10 * time.Millisecond)
    fmt.Println("shift closed")
}

Run:

go run after_func.go

Output:

shift active
async cleanup: releasing desk locks
shift closed

Calling the returned stop() function unregisters the callback if the operation finishes successfully before cancellation occurs.

The trap

Save as instant_exit.go. Calling os.Exit(0) immediately upon receiving a signal terminates the runtime without running deferred cleanups or finishing in-flight database transactions.

// instant_exit.go
package main

import (
    "fmt"
)

func cleanup() {
    fmt.Println("closing desk cash register")
}

func main() {
    defer cleanup()

    // Pretend a signal was caught and someone called os.Exit
    fmt.Println("signal received")
    // If os.Exit(1) were called here, 'cleanup' would be skipped!
    // Instead, let main return naturally.
}

Run:

go run instant_exit.go

Output:

signal received
closing desk cash register

Do not call os.Exit inside signal handlers. Cancel the context, allow functions to return, execute defer blocks, and exit cleanly through main.

The boring rule

  • Wrap process entry with signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM).
  • Always defer stop() to reset signal handling behavior.
  • Propagate ctx to all workers and background tasks.
  • For HTTP servers, catch signal cancel and call server.Shutdown(shutdownCtx).
  • Avoid os.Exit on shutdown; allow defer statements to clean up resources.

Try this

  1. In signal_stop.go, print context.Cause(ctx) after calling stop().
  2. In desk_shift_signal.go, reduce timeout to 10 * time.Millisecond and watch the clean context cancellation message appear.
  3. Extend graceful_http.go with a second route GET /orders and verify its response.