Go 优雅退出:Shutdown 之后,后台 goroutine 还在跑(四个实测的坑)

0 阅读6分钟

几乎每个 Go 服务的 main 函数里都有这么一段:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()

shCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shCtx)

看起来很完整:收到信号,给 5 秒让正在处理的请求跑完,然后退出。大家管这叫「优雅退出」。

但 http.Server.Shutdown 只管一件事:HTTP 连接。它不知道你在 handler 里往 channel 丢了一个任务、起了一个 goroutine 去发消息、或者有个 ticker 每分钟刷一次缓存。这些东西在进程退出的那一刻会被直接掐断,不报错,不留日志。

下面是我本地写 demo 实测的四个坑,环境是 Go 1.25 / macOS,代码都能直接跑。

坑一:请求返回了 200,任务却没做

最典型的写法:handler 收到请求后,把耗时的活丢给后台 worker 池,立刻返回「已受理」。

type Worker struct {
	jobs chan int
	wg   sync.WaitGroup
}

func NewWorker(n int) *Worker {
	w := &Worker{jobs: make(chan int, 100)}
	for i := 0; i < n; i++ {
		w.wg.Add(1)
		go func(id int) {
			defer w.wg.Done()
			for job := range w.jobs {
				time.Sleep(300 * time.Millisecond) // 模拟写库、发消息
				log.Printf("worker %d done job %d", id, job)
			}
		}(i)
	}
	return w
}

func (w *Worker) Submit(job int) bool {
	select {
	case w.jobs <- job:
		return true
	default:
		return false
	}
}

handler:

mux.HandleFunc("/order", func(rw http.ResponseWriter, r *http.Request) {
	id := nextID()
	time.Sleep(200 * time.Millisecond) // 模拟处理请求
	if !w.Submit(id) {
		http.Error(rw, "busy", http.StatusServiceUnavailable)
		return
	}
	fmt.Fprintf(rw, "accepted %d\n", id)
})

然后用开头那段「标准」退出逻辑。测试方法:起服务,并发打 10 个请求,350ms 后发 SIGTERM。

MODE=naive ./gs > naive.log 2>&1 & P=$!
sleep 0.8
for i in $(seq 1 10); do curl -s http://127.0.0.1:8088/order -o /dev/null -w "%{http_code} " & done
sleep 0.35; kill -TERM $P; wait $P

结果:

200 200 200 200 200 200 200 200 200 200 exit=0
2026/09/28 10:27:38 signal received
2026/09/28 10:27:38 server stopped, exit

10 个请求全部返回 200,日志里一条 done job 都没有。 进程退出码是 0,看起来一切正常。

Shutdown 确实等了那 10 个请求处理完,这点它做得没问题。问题是请求处理完 ≠ 任务处理完,任务还在 channel 里排队,worker 刚 sleep 到一半,main 函数就 return 了。

修法是给 worker 也加一个「停止并排空」的方法,在 HTTP 停完之后调用:

// Stop 关闭入口并等待已入队任务处理完,超时返回错误
func (w *Worker) Stop(ctx context.Context) error {
	close(w.jobs)
	done := make(chan struct{})
	go func() { w.wg.Wait(); close(done) }()
	select {
	case <-done:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}
<-ctx.Done()
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := srv.Shutdown(shCtx); err != nil { // 1. 先停入口,不再接新请求
	log.Println("http shutdown:", err)
}
if err := w.Stop(shCtx); err != nil {       // 2. 再排空后台任务
	log.Println("worker stop:", err)
}
log.Println("all done, exit")

同样的测试:

2026/09/28 10:27:32 worker 0 done job 10
2026/09/28 10:27:32 worker 1 done job 9
2026/09/28 10:27:32 all done, exit

10 个任务全部完成。

顺序很关键:必须先停 HTTP 再关 channel。反过来的话,Shutdown 期间还在处理的请求往已关闭的 channel 里 Submit,会直接 panic:send on closed channel。

坑二:主 goroutine 阻塞在 ListenAndServe 上

另一种很常见的结构,把信号处理放到 goroutine 里,main 阻塞在 ListenAndServe:

go func() {
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, syscall.SIGTERM)
	<-ch
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	srv.Shutdown(ctx)
}()

if err := srv.ListenAndServe(); err != nil {
	log.Println("ListenAndServe returned:", err)
}
log.Println("main exit")

我加了一个要跑 2 秒的 /slow 接口,请求发出去 0.3 秒后发 SIGTERM:

curl=000
2026/09/28 10:27:52 ListenAndServe returned: http: Server closed
2026/09/28 10:27:52 main exit

curl 拿到的是 000,连接被直接断掉了。

原因写在 Shutdown 的文档里:调用 Shutdown 后,ListenAndServe 会立即返回 ErrServerClosed,而不是等 Shutdown 完成。main 函数紧接着 return,进程退出,那个还在 sleep 的 handler 连同 Shutdown 自己一起被杀掉了。

修法:main 必须等 Shutdown 返回之后再退出。最简单的是把两者的位置对调,main 等信号、goroutine 跑 ListenAndServe(就是坑一里那种结构);或者用一个 channel 等 Shutdown 结束:

idle := make(chan struct{})
go func() {
	<-ctx.Done()
	shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	srv.Shutdown(shCtx)
	close(idle)
}()

if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
	log.Fatal(err)
}
<-idle // 等 Shutdown 真正结束

坑三:长连接 Shutdown 管不了

Shutdown 的文档还有一句容易被忽略的话:它不会尝试关闭或等待被 hijack 的连接,比如 WebSocket。

这意味着如果你的服务有 WebSocket 或者 SSE(SSE 没被 hijack,但它是一个永远不结束的请求,效果类似),Shutdown 会一直等到超时,然后返回 context deadline exceeded,客户端那边看到的是连接被硬断。

处理办法是用 RegisterOnShutdown 注册一个回调,主动通知这些连接收尾:

type Hub struct {
	mu    sync.Mutex
	conns map[*Conn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendClose("server restarting") // 发 close 帧 / SSE 里发一个 event: bye
	}
}

srv.RegisterOnShutdown(hub.CloseAll)

对 SSE 来说,更简单的做法是 handler 里同时 select 请求 ctx 和一个全局的 shutdown 信号:

func sse(w http.ResponseWriter, r *http.Request) {
	flusher := w.(http.Flusher)
	w.Header().Set("Content-Type", "text/event-stream")
	for {
		select {
		case <-r.Context().Done():
			return
		case <-shuttingDown: // 全局 channel,退出时 close
			fmt.Fprint(w, "event: bye\ndata: reconnect\n\n")
			flusher.Flush()
			return
		case msg := <-updates:
			fmt.Fprintf(w, "data: %s\n\n", msg)
			flusher.Flush()
		}
	}
}

客户端收到 bye 后自己重连,就会连到新实例上。

坑四:K8s 下 SIGTERM 来了,流量还在进

前面三个坑在单机上就能复现。上了 K8s 还有一个:Pod 被删除时,发 SIGTERM 和从 Service 的 Endpoints 里摘掉这个 Pod 是并行发生的。kube-proxy / Ingress 同步规则需要时间,所以收到 SIGTERM 之后的几百毫秒到几秒内,仍然会有新请求打进来。

如果你一收到信号就调 Shutdown,监听端口被关闭,这些请求拿到的就是 connection refused。

常见处理:收到信号后先让 readiness 探针失败、再等几秒,然后才 Shutdown。

var ready atomic.Bool
ready.Store(true)

mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
	if !ready.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

<-ctx.Done()
ready.Store(false)
time.Sleep(5 * time.Second) // 等负载均衡把自己摘掉,时长按集群实际情况定
srv.Shutdown(shCtx)

或者在 Pod 上配 preStop 执行 sleep 5,效果一样,业务代码不用改。

还要注意一个数字:K8s 默认 terminationGracePeriodSeconds 是 30 秒,超时直接 SIGKILL。「摘流量的等待 + HTTP Shutdown 超时 + 排空后台任务的时间」加起来必须小于它,否则你写的所有优雅逻辑都会在第 30 秒被强杀打断。

把四个坑合在一起

func main() {
	w := NewWorker(4)
	srv := &http.Server{Addr: ":8080", Handler: newMux(w)}

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()
	srv.RegisterOnShutdown(hub.CloseAll)       // 坑三:长连接主动收尾

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()                               // 坑二:main 自己等信号

	ready.Store(false)                         // 坑四:先摘流量
	time.Sleep(5 * time.Second)

	shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := srv.Shutdown(shCtx); err != nil {
		log.Println("http shutdown:", err)
	}
	if err := w.Stop(shCtx); err != nil {      // 坑一:排空后台任务
		log.Println("worker stop:", err)
	}
	log.Println("bye")
}

5 + 20 = 25 秒,留 5 秒余量给 30 秒的 grace period。

说说局限

排空只能保证「进程内已经接住的任务」不丢,进程被 SIGKILL、OOM、机器掉电的时候照样丢。真正不能丢的任务(扣款、发货通知),光靠优雅退出是不够的,得先落库或进消息队列再返回 200,worker 从队列里取、处理完再 ack。优雅退出只是减少丢失,不是消灭丢失。

另外坑四的等待时长没有标准答案,我上面写的 5 秒只是一个常见起点,具体要看你集群里 Endpoints 同步有多快。

我自己给 forxi.cn 写后端时有个习惯:每加一种后台 goroutine,都顺手问一句「它在退出时怎么收尾」。这个问题比写 Shutdown 那几行重要得多。