信号处理:让你的应用优雅地退出

学习 Go 的信号处理和优雅退出机制,构建生产级的应用程序

信号处理:让你的应用优雅地退出

你有没有遇到过这样的情况:

  • 你的 Web 服务器正在处理请求,突然被 Ctrl+C 终止,正在处理的请求直接断开
  • 你的后台任务正在写数据库,进程被 kill 了,数据写了一半
  • 你的应用收到 SIGTERM 信号(比如 Kubernetes 要重启你的 Pod),但应用直接退出了,没有做任何清理工作

这些问题都可以通过优雅退出(Graceful Shutdown)来解决。

今天我们就来学习如何在 Go 中处理信号,让你的应用能够优雅地退出。

Unix 信号基础

在 Unix/Linux 系统中,信号是一种进程间通信机制。常见的信号包括:

信号说明默认行为
SIGINT中断信号(Ctrl+C)终止进程
SIGTERM终止信号终止进程
SIGKILL强制终止立即终止(不可捕获)
SIGHUP挂起信号终止进程
SIGUSR1用户自定义信号 1终止进程
SIGUSR2用户自定义信号 2终止进程

⚠️ 重要SIGKILLSIGSTOP 信号不能被捕获或忽略

os/signal 包

Go 的 os/signal 包提供了信号处理的功能:

package main

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

func main() {
	// 创建一个接收信号的 channel
	sigChan := make(chan os.Signal, 1)
	
	// 注册要监听的信号
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
	
	fmt.Println("应用启动,按 Ctrl+C 退出...")
	
	// 等待信号
	sig := <-sigChan
	fmt.Printf("收到信号: %v\n", sig)
	
	// 执行清理工作
	fmt.Println("正在清理...")
	cleanup()
	
	fmt.Println("再见!")
}

func cleanup() {
	// 关闭数据库连接、保存状态等
}

运行后按 Ctrl+C

应用启动,按 Ctrl+C 退出...
^C收到信号: interrupt
正在清理...
再见!

多信号处理

有时候我们需要根据不同的信号执行不同的逻辑:

package main

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

func main() {
	sigChan := make(chan os.Signal, 1)
	
	// 监听多个信号
	signal.Notify(sigChan,
		syscall.SIGINT,   // Ctrl+C
		syscall.SIGTERM,  // kill 命令
		syscall.SIGHUP,   // 终端断开
		syscall.SIGUSR1,  // 自定义信号(比如重新加载配置)
	)
	
	fmt.Println("应用启动...")
	
	for sig := range sigChan {
		switch sig {
		case syscall.SIGINT, syscall.SIGTERM:
			fmt.Println("收到终止信号,准备退出...")
			gracefulShutdown()
			return
			
		case syscall.SIGHUP:
			fmt.Println("收到 SIGHUP,重新加载配置...")
			reloadConfig()
			
		case syscall.SIGUSR1:
			fmt.Println("收到 SIGUSR1,执行自定义操作...")
			customAction()
		}
	}
}

func gracefulShutdown() {
	fmt.Println("优雅退出中...")
}

func reloadConfig() {
	fmt.Println("配置已重新加载")
}

func customAction() {
	fmt.Println("自定义操作已执行")
}

发送信号:

# 发送 SIGUSR1 信号
kill -USR1 <pid>

# 发送 SIGHUP 信号
kill -HUP <pid>

优雅退出 HTTP 服务器

这是最常见的场景。Go 1.8+ 的 http.Server 内置了 Shutdown 方法:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)  // 模拟慢请求
		fmt.Fprintln(w, "Hello, World!")
	})
	
	server := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	
	// 在 goroutine 中启动服务器
	go func() {
		log.Println("服务器启动在 :8080")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("服务器启动失败: %v", err)
		}
	}()
	
	// 等待中断信号
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	
	log.Println("正在关闭服务器...")
	
	// 创建一个 30 秒超时的 context
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	
	// 优雅关闭
	if err := server.Shutdown(ctx); err != nil {
		log.Fatalf("服务器关闭失败: %v", err)
	}
	
	log.Println("服务器已优雅关闭")
}

测试:

# 终端 1:启动服务器
go run main.go

# 终端 2:发送请求
curl http://localhost:8080/

# 在请求处理过程中,在终端 1 按 Ctrl+C
# 服务器会等待请求处理完成后再退出

完整的优雅退出框架

让我们构建一个生产级的优雅退出框架:

package main

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

// App 应用程序
type App struct {
	server     *http.Server
	db         *Database
	cache      *Cache
	workers    []*Worker
	shutdownCh chan struct{}
	wg         sync.WaitGroup
}

type Database struct {
	connected bool
}

func (db *Database) Close() error {
	log.Println("关闭数据库连接...")
	db.connected = false
	return nil
}

type Cache struct {
	running bool
}

func (c *Cache) Close() error {
	log.Println("关闭缓存...")
	c.running = false
	return nil
}

type Worker struct {
	id      int
	ctx     context.Context
	cancel  context.CancelFunc
}

func (w *Worker) Start() {
	log.Printf("Worker %d 启动", w.id)
	for {
		select {
		case <-w.ctx.Done():
			log.Printf("Worker %d 退出", w.id)
			return
		default:
			// 模拟工作
			time.Sleep(1 * time.Second)
			log.Printf("Worker %d 工作中...", w.id)
		}
	}
}

func NewApp() *App {
	return &App{
		shutdownCh: make(chan struct{}),
	}
}

func (app *App) Start() error {
	// 初始化数据库
	app.db = &Database{connected: true}
	log.Println("数据库已连接")
	
	// 初始化缓存
	app.cache = &Cache{running: true}
	log.Println("缓存已启动")
	
	// 启动后台 workers
	ctx, cancel := context.WithCancel(context.Background())
	for i := 1; i <= 3; i++ {
		worker := &Worker{
			id:     i,
			ctx:    ctx,
			cancel: cancel,
		}
		app.workers = append(app.workers, worker)
		
		app.wg.Add(1)
		go func(w *Worker) {
			defer app.wg.Done()
			w.Start()
		}(worker)
	}
	
	// 启动 HTTP 服务器
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, World!")
	})
	
	app.server = &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}
	
	go func() {
		log.Println("HTTP 服务器启动在 :8080")
		if err := app.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Printf("HTTP 服务器错误: %v", err)
		}
	}()
	
	return nil
}

func (app *App) Shutdown(timeout time.Duration) error {
	log.Println("开始优雅退出...")
	
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()
	
	// 1. 停止接收新请求
	log.Println("停止 HTTP 服务器...")
	if err := app.server.Shutdown(ctx); err != nil {
		log.Printf("HTTP 服务器关闭错误: %v", err)
	}
	
	// 2. 通知所有 worker 退出
	log.Println("通知 workers 退出...")
	for _, worker := range app.workers {
		worker.cancel()
	}
	
	// 3. 等待所有 worker 完成
	log.Println("等待 workers 完成...")
	app.wg.Wait()
	
	// 4. 关闭数据库和缓存
	log.Println("关闭数据库和缓存...")
	app.db.Close()
	app.cache.Close()
	
	log.Println("优雅退出完成")
	return nil
}

func main() {
	app := NewApp()
	
	if err := app.Start(); err != nil {
		log.Fatalf("启动失败: %v", err)
	}
	
	// 等待信号
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	sig := <-quit
	
	log.Printf("收到信号: %v", sig)
	
	// 优雅退出,超时 30 秒
	if err := app.Shutdown(30 * time.Second); err != nil {
		log.Fatalf("退出失败: %v", err)
	}
}

使用 errgroup

golang.org/x/sync/errgroup 可以更优雅地管理多个 goroutine:

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
	
	"golang.org/x/sync/errgroup"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	
	g, ctx := errgroup.WithContext(ctx)
	
	// 启动 HTTP 服务器
	g.Go(func() error {
		server := &http.Server{Addr: ":8080"}
		
		// 监听关闭信号
		go func() {
			<-ctx.Done()
			server.Shutdown(context.Background())
		}()
		
		log.Println("HTTP 服务器启动")
		return server.ListenAndServe()
	})
	
	// 启动后台任务
	g.Go(func() error {
		ticker := time.NewTicker(5 * time.Second)
		defer ticker.Stop()
		
		for {
			select {
			case <-ctx.Done():
				log.Println("后台任务退出")
				return nil
			case <-ticker.C:
				log.Println("后台任务执行中...")
			}
		}
	})
	
	// 监听信号
	g.Go(func() error {
		quit := make(chan os.Signal, 1)
		signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
		
		select {
		case sig := <-quit:
			log.Printf("收到信号: %v", sig)
			cancel()
		case <-ctx.Done():
		}
		
		return nil
	})
	
	// 等待所有 goroutine 完成
	if err := g.Wait(); err != nil {
		log.Printf("退出错误: %v", err)
	}
	
	log.Println("应用已退出")
}

systemd 集成

在 Linux 上,配合 systemd 使用:

# /etc/systemd/system/myapp.service
[Unit]
Description=My Go Application
After=network.target

[Service]
Type=simple
User=www-data
ExecStart=/usr/local/bin/myapp
Restart=always
RestartSec=5

# 优雅退出的超时时间
TimeoutStopSec=30

# 发送 SIGTERM 信号
KillMode=mixed
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target

重启服务时,systemd 会先发送 SIGTERM,等待 TimeoutStopSec 秒,如果还没退出再发送 SIGKILL

Docker 集成

在 Docker 中,确保正确处理信号:

FROM golang:1.16-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/myapp .

# 使用 exec 形式,确保信号能正确传递
CMD ["./myapp"]

⚠️ 注意:不要用 shell 形式 CMD ./myapp,否则信号无法传递到 Go 程序。

小结

今天我们学习了 Go 的信号处理和优雅退出:

  1. 信号基础:SIGINT、SIGTERM、SIGHUP 等
  2. os/signal 包:监听和处理信号
  3. HTTP 优雅退出http.Server.Shutdown
  4. 完整框架:多组件协调退出
  5. errgroup:管理多个 goroutine
  6. 系统集成:systemd、Docker

优雅退出是生产级应用的必备特性。它确保应用在关闭时能够完成正在进行的工作、保存状态、释放资源,给用户一个良好的体验。

练习时间

  1. 多阶段退出:实现分阶段的优雅退出(先停止接收请求,再等待任务完成,最后清理资源)
  2. 健康检查:在退出过程中返回 503 状态码
  3. 状态持久化:退出时保存应用状态,启动时恢复
  4. 优雅重启:实现不中断服务的热重启(参考 tableflip 或 overseer 库)

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页