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

信号处理:让你的应用优雅地退出 你有没有遇到过这样的情况: - 你的应用收到 信号(比如 Kubernetes 要重启你的 Pod),但应用直接退出了,没有做任何清理工作 这些问题都可以通过 优雅退出 (Graceful Shutdown)来解决。

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

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

  • 你的 Web 服务器正在处理请求,突然被 Ctrl+C 终止,正在处理的请求直接断开
  • 你的后台任务正在写数据库,进程被 kill 了,数据写了一半
  • 你的应用收到 SIGTERM 信号(比如 Kubernetes 要重启你的 Pod),但应用直接退出了,没有做任何清理工作
  • 你的日志文件没来得及刷新到磁盘,进程就被强制结束了
  • 你的缓存还没来得及持久化到数据库,服务就停止了

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

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

Unix 信号基础

什么是信号?

在 Unix/Linux 系统中,信号是一种进程间通信机制,用于通知进程发生了某个事件。信号是异步的——进程可能在任何时候收到信号。

操作系统、其他进程或用户都可以向进程发送信号。每个信号都有一个编号和一个名称。例如,SIGINT 的编号是 2,SIGKILL 的编号是 9。

常见信号一览

信号编号说明默认行为可捕获
SIGHUP1挂起信号(终端断开)终止进程
SIGINT2中断信号(Ctrl+C)终止进程
SIGQUIT3退出信号(Ctrl+\)终止+core dump
SIGILL4非法指令终止+core dump
SIGABRT6异常终止终止+core dump
SIGFPE8浮点异常终止+core dump
SIGKILL9强制终止立即终止
SIGSEGV11段错误终止+core dump
SIGPIPE13管道断裂终止进程
SIGALRM14定时器信号终止进程
SIGTERM15终止信号终止进程
SIGUSR110/30/16用户自定义信号 1终止进程
SIGUSR212/31/17用户自定义信号 2终止进程
SIGCHLD17/20/18子进程状态改变忽略
SIGCONT18/19/25继续执行继续
SIGSTOP19/17/23停止执行停止
SIGTSTP20/18/24终端停止(Ctrl+Z)停止

重要SIGKILL(9)和 SIGSTOP 信号不能被捕获、阻塞或忽略。这是操作系统的最后手段,确保管理员始终能终止失控的进程。

信号的生命周期

发送信号                    接收信号                  处理信号
┌─────────┐                ┌─────────┐              ┌─────────┐
│ killcmd │───SIGTERM─────▶│ 进程    │───处理中───▶│ 清理资源│
│ Ctrl+C  │                │         │              │ 退出程序│
│ 系统    │                │         │              │ 忽略    │
└─────────┘                └─────────┘              └─────────┘

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
正在清理...
再见!

忽略和恢复信号

// 忽略 SIGPIPE 信号(常用于网络编程)
signal.Ignore(syscall.SIGPIPE)

// 恢复信号的默认处理
signal.Reset(syscall.SIGINT)

// 停止接收信号(但之前发送的信号可能还在 channel 中)
signal.Stop(sigChan)

多信号处理

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

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>

# 强制终止(不可捕获)
kill -9 <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
# 服务器会等待请求处理完成后再退出

带健康检查的优雅退出

在生产环境中,你可以在开始关闭时让健康检查端点返回 503,这样负载均衡器就知道不要把新请求发给你了:

type App struct {
    server   *http.Server
    shutting bool
    mu       sync.RWMutex
}

func (a *App) healthHandler(w http.ResponseWriter, r *http.Request) {
    a.mu.RLock()
    shutting := a.shutting
    a.mu.RUnlock()

    if shutting {
        w.WriteHeader(http.StatusServiceUnavailable)
        fmt.Fprintln(w, `{"status":"shutting_down"}`)
        return
    }
    fmt.Fprintln(w, `{"status":"ok"}`)
}

func (a *App) Shutdown(ctx context.Context) error {
    a.mu.Lock()
    a.shutting = true
    a.mu.Unlock()

    // 给负载均衡器一点时间发现状态变化
    time.Sleep(5 * time.Second)

    return a.server.Shutdown(ctx)
}

完整的优雅退出框架

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

package main

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

// 可关闭组件接口
type Closable interface {
    Close() error
}

// App 应用程序
type App struct {
    components []Closable
    server     *http.Server
    shutdownCh chan struct{}
    wg         sync.WaitGroup
    mu         sync.Mutex
}

// Database 模拟数据库连接
type Database struct {
    connected bool
}

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

// Cache 模拟缓存服务
type Cache struct {
    running bool
}

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

// Worker 后台工作器
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) RegisterComponent(c Closable) {
    app.mu.Lock()
    defer app.mu.Unlock()
    app.components = append(app.components, c)
}

func (app *App) Start() error {
    // 初始化数据库
    db := &Database{connected: true}
    app.RegisterComponent(db)
    log.Println("数据库已连接")

    // 初始化缓存
    cache := &Cache{running: true}
    app.RegisterComponent(cache)
    log.Println("缓存已启动")

    // 启动后台 workers
    ctx, cancel := context.WithCancel(context.Background())
    for i := 1; i <= 3; i++ {
        worker := &Worker{
            id:     i,
            ctx:    ctx,
            cancel: cancel,
        }

        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 退出...")
    // workers 的 cancel 在这里调用

    // 3. 等待所有 worker 完成
    log.Println("等待 workers 完成...")
    app.wg.Wait()

    // 4. 关闭所有组件
    log.Println("关闭所有组件...")
    app.mu.Lock()
    components := app.components
    app.mu.Unlock()

    for _, c := range components {
        if err := c.Close(); err != nil {
            log.Printf("关闭组件错误: %v", err)
        }
    }

    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("应用已退出")
}

超时与强制退出

有时候优雅退出会卡住(比如某个请求永远处理不完),这时候需要强制退出:

func main() {
    app := NewApp()
    app.Start()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    // 两个阶段退出
    // 阶段 1:优雅退出(30秒超时)
    done := make(chan struct{})
    go func() {
        app.Shutdown(30 * time.Second)
        close(done)
    }()

    // 阶段 2:如果优雅退出超时,强制退出
    select {
    case <-done:
        log.Println("优雅退出完成")
    case <-time.After(35 * time.Second):
        log.Println("优雅退出超时,强制退出")
        os.Exit(1)
    }
}

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

# 查看服务状态
systemctl status myapp

# 优雅重启
systemctl restart myapp

# 停止服务(发送 SIGTERM)
systemctl stop myapp

# 查看日志
journalctl -u myapp -f

Docker 集成

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

FROM golang:1.22-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 程序。

Docker Stop 的信号传递

# Docker stop 默认发送 SIGTERM,等待 10 秒后发送 SIGKILL
docker stop myapp-container

# 自定义超时时间
docker stop -t 30 myapp-container

# docker kill 直接发送 SIGKILL(不推荐用于生产)
docker kill myapp-container

Docker Compose 配置

version: '3.8'
services:
  app:
    build: .
    stop_signal: SIGTERM
    stop_grace_period: 30s

Kubernetes 集成

在 Kubernetes 中,Pod 删除时:

  1. Pod 状态变为 “Terminating”
  2. Service 从 endpoints 中移除该 Pod
  3. Kubelet 发送 SIGTERM 到容器
  4. 等待 terminationGracePeriodSeconds(默认 30 秒)
  5. 如果还未退出,发送 SIGKILL
apiVersion: v1
kind: Pod
spec:
  containers:
    - name: myapp
      image: myapp:latest
      # 优雅退出时间
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh", "-c", "sleep 10"]
  # 总的优雅退出时间
  terminationGracePeriodSeconds: 60

注意preStop 钩子会在 SIGTERM 之前执行。如果你需要等待负载均衡器将 Pod 从 endpoints 中移除,preStop 中加一个 sleep 是很有用的。

常见问题(FAQ)

Q: 为什么我的程序收不到信号?
A: 常见原因:1)使用了 shell 形式的 Docker CMD;2)信号被父进程拦截;3)channel 容量为 0 导致死锁。

Q: SIGKILLSIGTERM 有什么区别?
A: SIGTERM(15)是请求终止,可以被捕获和处理;SIGKILL(9)是强制终止,无法被捕获。

Q: 优雅退出的超时时间应该设多长?
A: 取决于你的业务场景。一般 Web 服务 30 秒足够,后台任务可能需要更长。要确保小于 Kubernetes 的 terminationGracePeriodSeconds

Q: 如何在 Windows 上处理信号?
A: Windows 支持的信号有限。Go 的 os/signal 在 Windows 上主要支持 os.Interrupt(Ctrl+C)。生产环境建议使用 Linux。

Q: 多个 goroutine 监听同一个信号会怎样?
A: signal.Notify 使用广播机制,所有注册的 channel 都会收到信号。

小结

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

  1. 信号基础:SIGINT、SIGTERM、SIGHUP 等信号的含义和默认行为
  2. os/signal 包:监听和处理信号的基本方法
  3. HTTP 优雅退出http.Server.Shutdown 的正确使用
  4. 完整框架:多组件协调退出的生产级方案
  5. errgroup:更优雅的 goroutine 管理
  6. 超时与强制退出:防止优雅退出卡住的策略
  7. 系统集成:systemd、Docker、Kubernetes 的配置要点

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

延伸阅读:

练习时间

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

我们下篇见!👋


参考资料:

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南