信号处理:让你的应用优雅地退出
你有没有遇到过这样的情况:
- 你的 Web 服务器正在处理请求,突然被
Ctrl+C终止,正在处理的请求直接断开 - 你的后台任务正在写数据库,进程被 kill 了,数据写了一半
- 你的应用收到
SIGTERM信号(比如 Kubernetes 要重启你的 Pod),但应用直接退出了,没有做任何清理工作 - 你的日志文件没来得及刷新到磁盘,进程就被强制结束了
- 你的缓存还没来得及持久化到数据库,服务就停止了
这些问题都可以通过优雅退出(Graceful Shutdown)来解决。
今天我们就来学习如何在 Go 中处理信号,让你的应用能够优雅地退出。
Unix 信号基础
什么是信号?
在 Unix/Linux 系统中,信号是一种进程间通信机制,用于通知进程发生了某个事件。信号是异步的——进程可能在任何时候收到信号。
操作系统、其他进程或用户都可以向进程发送信号。每个信号都有一个编号和一个名称。例如,SIGINT 的编号是 2,SIGKILL 的编号是 9。
常见信号一览
| 信号 | 编号 | 说明 | 默认行为 | 可捕获 |
|---|---|---|---|---|
SIGHUP | 1 | 挂起信号(终端断开) | 终止进程 | 是 |
SIGINT | 2 | 中断信号(Ctrl+C) | 终止进程 | 是 |
SIGQUIT | 3 | 退出信号(Ctrl+\) | 终止+core dump | 是 |
SIGILL | 4 | 非法指令 | 终止+core dump | 否 |
SIGABRT | 6 | 异常终止 | 终止+core dump | 是 |
SIGFPE | 8 | 浮点异常 | 终止+core dump | 否 |
SIGKILL | 9 | 强制终止 | 立即终止 | 否 |
SIGSEGV | 11 | 段错误 | 终止+core dump | 否 |
SIGPIPE | 13 | 管道断裂 | 终止进程 | 是 |
SIGALRM | 14 | 定时器信号 | 终止进程 | 是 |
SIGTERM | 15 | 终止信号 | 终止进程 | 是 |
SIGUSR1 | 10/30/16 | 用户自定义信号 1 | 终止进程 | 是 |
SIGUSR2 | 12/31/17 | 用户自定义信号 2 | 终止进程 | 是 |
SIGCHLD | 17/20/18 | 子进程状态改变 | 忽略 | 是 |
SIGCONT | 18/19/25 | 继续执行 | 继续 | 是 |
SIGSTOP | 19/17/23 | 停止执行 | 停止 | 否 |
SIGTSTP | 20/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 删除时:
- Pod 状态变为 “Terminating”
- Service 从 endpoints 中移除该 Pod
- Kubelet 发送
SIGTERM到容器 - 等待
terminationGracePeriodSeconds(默认 30 秒) - 如果还未退出,发送
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: SIGKILL 和 SIGTERM 有什么区别?
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 的信号处理和优雅退出:
- 信号基础:SIGINT、SIGTERM、SIGHUP 等信号的含义和默认行为
- os/signal 包:监听和处理信号的基本方法
- HTTP 优雅退出:
http.Server.Shutdown的正确使用 - 完整框架:多组件协调退出的生产级方案
- errgroup:更优雅的 goroutine 管理
- 超时与强制退出:防止优雅退出卡住的策略
- 系统集成:systemd、Docker、Kubernetes 的配置要点
优雅退出是生产级应用的必备特性。它确保应用在关闭时能够完成正在进行的工作、保存状态、释放资源,给用户一个良好的体验。
延伸阅读:
练习时间
- 多阶段退出:实现分阶段的优雅退出(先停止接收请求,再等待任务完成,最后清理资源)
- 健康检查:在退出过程中返回 503 状态码
- 状态持久化:退出时保存应用状态,启动时恢复
- 优雅重启:实现不中断服务的热重启(参考 tableflip 或 overseer 库)
- 信号日志:实现一个信号接收日志,记录所有收到的信号和时间
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。