Skip to main content

Goroutine 與 Context 生命週期

核心原則:每個被 spawn 的 goroutine 都必須有明確的結束路徑(exit path),通常透過 context 的 cancel 來控制。否則在 app graceful shutdown 時,這些 goroutine 可能卡住程序,造成 goroutine leak


為什麼會漏?

HTTP handler 常會「先回應用戶、背景再做事」(例如 enqueue SQS)。這時容易犯兩個錯:

  1. 直接用 request context:request 一結束就 cancel,背景工作可能被提早掐掉。
  2. 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 而 cancelrequest 回傳後仍要跑完、但關機時要能停的背景工作需在迴圈/阻塞點檢查 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")
}
}()
}

重點:

  1. t.appCtx.Context:shutdown 時會被 cancel,goroutine 有結束依據。
  2. c.FieldLogger:仍帶得到 request 維度的欄位,方便追蹤。
  3. select + <-appCtx.Done():在迴圈每次迭代提供明確 exit path(尤其是長列表 enqueue)。
  4. Publish(appCtx, …):若 publisher 有尊重 context,阻塞中也能被取消。

修正後若檔案內不再使用 "context" 套件,記得移除 unused import。


改動對照

項目BeforeAfter
Context 來源context.WithoutCancel(c)(request detach)t.appCtx.Context(app 生命週期)
Goroutine exit path無,可能永遠存活select <-appCtx.Done()
Log fieldsc.FieldLoggerc.FieldLogger
Log error key"err""error"(與專案慣例一致)

實作檢查清單

spawn 背景 goroutine 前自問:

  • 這個工作該跟 request 還是 app 同壽命?
  • cancel 時,goroutine 是否會在合理時間內結束?(Done()、可取消的 I/O、timeout)
  • 是否避免「永不 cancel」的 context,除非另有明確結束機制(channel close、WaitGroup + shutdown hook 等)?
  • logger 是否仍保留必要的 request 關聯欄位?

一句話記住

Request 結束 ≠ 背景工作該死;App 關機 = 背景工作必須能停。
app context 管生命週期,用 request logger 管可觀測性;別用 WithoutCancel 換來「永遠跑不完」的 goroutine。