Goroutine 與 Context 生命週期
核心原則:每個被 spawn 的 goroutine 都必須有明確的結束路徑(exit path),通常透過 context 的 cancel 來控制。否則在 app graceful shutdown 時,這些 goroutine 可能卡住程序,造成 goroutine leak。
為什麼會漏?
HTTP handler 常會「先回應用戶、背景再做事」(例如 enqueue SQS)。這時容易犯兩個錯:
- 直接用 request context:request 一結束就 cancel,背景工作可能被提早掐掉。
- 用
context.WithoutCancel(c)完全 detach:背景工作活下來了,但 app shutdown 時也收不到取消信號,變成沒有 exit path。
正確做法是:背景工作綁 app-level context(隨程序關閉而 cancel),同時可保留 request 的 logger fields( 如 request_id)。
三種 Context 的差異
| Context | 生命週期 | 適合場景 | 風險 |
|---|---|---|---|
Request c | 隨 HTTP request 結束而 cancel | 同步處理、與請求同壽命的工作 | 背景 goroutine 可能被過早取消 |
context.WithoutCancel(c) | 永不因 parent cancel 而結束 | 極少數「必須與 request 脫鉤、且另有結束機制」的情況 | 無 shutdown exit path → goroutine leak |
App-level appCtx | 隨程序 / graceful shutdown 而 cancel | request 回傳後仍要跑完、但關機時要能停的背景工作 | 需在迴圈/阻塞點檢查 Done() |
Request CTX ──cancel──► HTTP 結束就停(不適合長背景)
WithoutCancel ──✕──► 永遠不因 cancel 停(缺 exit path)
App CTX ──shutdown──► 關機時可停(正確的背景工作綁定)
反例:WithoutCancel 切斷生命週期
// ❌ 問題:完全 detach,goroutine 在 app shutdown 時無法被終止
func (t *FTSSQSTrigger) TriggerForDocuments(c ctx.CTX, docIDs []int64) {
if t == nil || len(docIDs) == 0 {
return
}
bg := ctx.CTX{
Context: context.WithoutCancel(c), // 永遠不會被 cancel
FieldLogger: c.FieldLogger,
}
go func() {
for _, docID := range docIDs {
msg := ftsM.FullTextIndexMessage{
DataType: ftsM.DataTypeFullTextIndex,
DocumentID: docID,
}
if err := t.publisher.Publish(bg, msg); err != nil {
bg.WithFields(logrus.Fields{
"document_id": docID,
"err": err, // log key 也不一致
}).Warn("ftsware sqs: failed to enqueue index message")
continue
}
bg.WithField("document_id", docID).Debug("ftsware sqs: index message enqueued")
}
}()
}
問題 摘要:
- 無 exit path:app graceful shutdown 時這個 goroutine 收不到取消信號,可能拖住程序退出。
- 違反 Concurrency 規範:spawn 出的 goroutine 必須能被明確結束,通常由 context 控制。
正例:綁 appCtx + 迴圈內檢查 Done()
// ✅ 修正:綁 app 生命週期,並保留 request log fields
func (t *FTSSQSTrigger) TriggerForDocuments(c ctx.CTX, docIDs []int64) {
if t == nil || len(docIDs) == 0 {
return
}
// Context 跟 app 走;logger 跟 request 走(request_id 等)
appCtx := ctx.CTX{
Context: t.appCtx.Context,
FieldLogger: c.FieldLogger,
}
go func() {
for _, docID := range docIDs {
// Clear exit path:shutdown 時立刻停止後續 enqueue
select {
case <-appCtx.Done():
appCtx.Warn("ftsware sqs: context done, aborting remaining enqueues")
return
default:
}
msg := ftsM.FullTextIndexMessage{
DataType: ftsM.DataTypeFullTextIndex,
DocumentID: docID,
}
if err := t.publisher.Publish(appCtx, msg); err != nil {
appCtx.WithFields(logrus.Fields{
"document_id": docID,
"error": err, // 與專案慣例一致用 "error"
}).Warn("ftsware sqs: failed to enqueue index message")
continue
}
appCtx.WithField("document_id", docID).Debug("ftsware sqs: index message enqueued")
}
}()
}
重點:
t.appCtx.Context:shutdown 時會被 cancel,goroutine 有結束依據。c.FieldLogger:仍帶得到 request 維度的欄位,方便追蹤。select+<-appCtx.Done():在迴圈每次迭代提供明確 exit path(尤其是長列表 enqueue)。Publish(appCtx, …):若 publisher 有尊重 context,阻塞中也能被取消。
修正後若檔案內不再使用 "context" 套件,記得移除 unused import。
改動對照
| 項目 | Before | After |
|---|---|---|
| Context 來源 | context.WithoutCancel(c)(request detach) | t.appCtx.Context(app 生命週期) |
| Goroutine exit path | 無,可能永遠存活 | select <-appCtx.Done() |
| Log fields | c.FieldLogger ✅ | c.FieldLogger ✅ |
| Log error key | "err" | "error"(與專案慣例一致) |