Signal Handling Fundamentals

At the core of graceful shutdown is the ability to detect and respond to termination signals. Before diving into complex implementations, let’s establish a solid understanding of signal handling in Go.

Understanding OS Signals

Operating systems communicate with processes through signals. The most common signals relevant to application lifecycle management include:

  1. SIGINT (Ctrl+C): Interrupt signal, typically sent when a user presses Ctrl+C
  2. SIGTERM: Termination signal, the standard way to request graceful termination
  3. SIGKILL: Kill signal, forces immediate termination (cannot be caught or ignored)
  4. SIGHUP: Hangup signal, traditionally used to indicate a controlling terminal has closed

In Go, we can capture and handle these signals using the os/signal package and channels:

package main

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

func main() {
	// Create a channel to receive OS signals
	sigs := make(chan os.Signal, 1)
	
	// Register for specific signals
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	
	// Create a channel to indicate when processing is done
	done := make(chan bool, 1)
	
	// Start a goroutine to handle signals
	go func() {
		// Block until a signal is received
		sig := <-sigs
		fmt.Printf("Received signal: %s\n", sig)
		
		// Perform cleanup operations
		fmt.Println("Starting graceful shutdown...")
		time.Sleep(2 * time.Second) // Simulate cleanup work
		fmt.Println("Cleanup completed, shutting down...")
		
		// Signal completion
		done <- true
	}()
	
	fmt.Println("Application running... Press Ctrl+C to terminate")
	
	// Block until done signal is received
	<-done
	fmt.Println("Application stopped")
}

This simple example demonstrates the basic pattern for signal handling in Go:

  1. Create a channel to receive signals
  2. Register for specific signals using signal.Notify()
  3. Start a goroutine to handle signals and perform cleanup
  4. Block the main goroutine until cleanup is complete

Context-Based Cancellation

Go’s context package provides a powerful mechanism for propagating cancellation signals throughout your application. This is particularly useful for graceful shutdown scenarios:

package main

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

func main() {
	// Create a base context with cancellation capability
	ctx, cancel := context.WithCancel(context.Background())
	
	// Create a channel to receive OS signals
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	
	// Create a WaitGroup to track active workers
	var wg sync.WaitGroup
	
	// Start some worker goroutines
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(ctx, i, &wg)
	}
	
	// Handle signals
	go func() {
		sig := <-sigs
		fmt.Printf("\nReceived signal: %s\n", sig)
		fmt.Println("Cancelling context...")
		cancel() // This will propagate cancellation to all workers
	}()
	
	fmt.Println("Application running with workers... Press Ctrl+C to terminate")
	
	// Wait for all workers to finish
	wg.Wait()
	fmt.Println("All workers have completed, shutting down...")
}

func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
	defer wg.Done()
	
	fmt.Printf("Worker %d starting\n", id)
	
	// Simulate work with context awareness
	for {
		select {
		case <-time.After(time.Second):
			fmt.Printf("Worker %d performing task\n", id)
		case <-ctx.Done():
			fmt.Printf("Worker %d received cancellation signal, cleaning up...\n", id)
			// Simulate cleanup work
			time.Sleep(time.Duration(id) * 500 * time.Millisecond)
			fmt.Printf("Worker %d cleanup complete\n", id)
			return
		}
	}
}

This example demonstrates how to use context cancellation to coordinate shutdown across multiple goroutines:

  1. Create a cancellable context
  2. Pass the context to all workers
  3. When a termination signal is received, call cancel() to notify all workers
  4. Use a WaitGroup to ensure all workers complete their cleanup before the application exits