Context and Process Signals
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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
ctxto all workers and background tasks. - For HTTP servers, catch signal cancel and call
server.Shutdown(shutdownCtx). - Avoid
os.Exiton shutdown; allowdeferstatements to clean up resources.
Try this
- In
signal_stop.go, printcontext.Cause(ctx)after callingstop(). - In
desk_shift_signal.go, reduce timeout to10 * time.Millisecondand watch the clean context cancellation message appear. - Extend
graceful_http.gowith a second routeGET /ordersand verify its response.