信号处理:让你的应用优雅地退出
你有没有遇到过这样的情况:
- 你的 Web 服务器正在处理请求,突然被
Ctrl+C终止,正在处理的请求直接断开 - 你的后台任务正在写数据库,进程被 kill 了,数据写了一半
- 你的应用收到
SIGTERM信号(比如 Kubernetes 要重启你的 Pod),但应用直接退出了,没有做任何清理工作
这些问题都可以通过优雅退出(Graceful Shutdown)来解决。
今天我们就来学习如何在 Go 中处理信号,让你的应用能够优雅地退出。
Unix 信号基础
在 Unix/Linux 系统中,信号是一种进程间通信机制。常见的信号包括:
| 信号 | 说明 | 默认行为 |
|---|---|---|
SIGINT | 中断信号(Ctrl+C) | 终止进程 |
SIGTERM | 终止信号 | 终止进程 |
SIGKILL | 强制终止 | 立即终止(不可捕获) |
SIGHUP | 挂起信号 | 终止进程 |
SIGUSR1 | 用户自定义信号 1 | 终止进程 |
SIGUSR2 | 用户自定义信号 2 | 终止进程 |
⚠️ 重要:SIGKILL 和 SIGSTOP 信号不能被捕获或忽略。
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 的信号处理和优雅退出:
- 信号基础:SIGINT、SIGTERM、SIGHUP 等
- os/signal 包:监听和处理信号
- HTTP 优雅退出:
http.Server.Shutdown - 完整框架:多组件协调退出
- errgroup:管理多个 goroutine
- 系统集成:systemd、Docker
优雅退出是生产级应用的必备特性。它确保应用在关闭时能够完成正在进行的工作、保存状态、释放资源,给用户一个良好的体验。
练习时间
- 多阶段退出:实现分阶段的优雅退出(先停止接收请求,再等待任务完成,最后清理资源)
- 健康检查:在退出过程中返回 503 状态码
- 状态持久化:退出时保存应用状态,启动时恢复
- 优雅重启:实现不中断服务的热重启(参考 tableflip 或 overseer 库)
我们下篇见!👋
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。