一個沒人負責關的 channel,讓我的服務 goroutine 越積越多——我真正用到的 Go 並發模式
Programming·2025年9月1日·5 分鐘閱讀

一個沒人負責關的 channel,讓我的服務 goroutine 越積越多——我真正用到的 Go 並發模式

先講一個我製造出來的 bug

一個沒人負責關的 channel,讓我的服務 goroutine 越積越多——我真正用到的 Go 並發模式:本文架構

講並發模式之前,先講一個我自己寫出來的坑,因為它比任何教科書都讓我記得牢。

有支服務上線一陣子之後,記憶體緩慢地、穩定地往上爬,重啟就好、過幾天又爬回來。抓了半天,發現是 goroutine 洩漏——我開了一堆 goroutine 去往一個 channel 送資料,但接收端在某些情況下提早結束了,沒有人再讀那個 channel。於是那些送資料的 goroutine,全部卡在「送不出去」的狀態,永遠不會結束,越積越多。

問題的根源不是什麼高深的東西,是我從頭到尾沒想清楚一件事:這些 goroutine 跟這個 channel,到底誰負責關、誰負責等它們結束。 這篇講的就是我從這類坑裡,真正沉澱下來、天天在用的幾個 Go 並發習慣。

心法:先想清楚誰負責關閉與等待

現在只要我要開 goroutine,動手前一定先問自己兩個問題:這個 goroutine 什麼時候、由誰來確保它會結束?如果它往 channel 送東西,萬一沒人收了,它會不會卡死?

這兩個問題想不清楚,我就不會動手寫。我那次的洩漏,就是因為當時我腦子裡只有「把活分出去給 goroutine 做」的爽感,完全沒想「善後」。並發最難的從來不是「怎麼把事情並行」,是「怎麼乾淨地收尾」。

用 context 串起取消訊號

收尾的第一個工具就是 context。我會把一個 context 往下傳給所有相關的 goroutine,當要收工(請求取消、服務關閉),就 cancel 這個 context,每個 goroutine 自己去監聽、收到就退出。

這樣「該停了」這個訊號有一個統一的來源,不用我一個個去戳它們。前提是——每個 goroutine 真的有在監聽這個訊號,這點我在另一篇講 context 的文章裡踩過更大的坑。

worker pool:把尖峰攤平

不是所有事情都適合「來一個請求就開一個 goroutine」。像我做搶購系統,瞬間幾萬個請求,你真的一個開一個 goroutine,機器直接被壓垮。

這種場景我用 worker pool:固定開一小群 worker,請求丟進一個佇列,worker 一個個拿來處理。好處是並發數是可控的,不會被突發流量沖爆。它把一個尖峰的洪水,變成一條穩定的水流。

撮合核心反而要「單執行緒」

這點很反直覺,但我做撮合引擎最大的體會之一:核心的撮合邏輯,我刻意讓它跑在單一 goroutine 裡。

因為訂單撮合對順序跟一致性的要求極高,一旦多個 goroutine 同時去改同一個訂單簿,你要用一堆鎖去保護,複雜度爆炸、還容易出微妙的競態。反而是把所有訂單透過 channel 排隊、丟給一個 goroutine 依序處理,邏輯乾淨、天然有序、完全沒有鎖的煩惱。並發不是越多執行緒越好,有時候「刻意單線」才是對的設計。

select 與逾時,別讓自己永遠卡住

從 channel 收資料,我幾乎不寫「死等」。一定會搭配 select 加上一個逾時或 context 的取消分支,這樣就算對面永遠不送東西過來,我也不會卡在那裡卡到天荒地老。我那次的 goroutine 洩漏,某種程度上就是「死等」的變形——差別只在卡的是送、不是收。

鎖不是壞東西,但要小心

Go 圈子很愛講「用 channel 通訊,不要用共享記憶體」。我大致認同,但也沒那麼教條。有些場景,比如保護一個簡單的共用計數器或設定,直接用一把鎖反而比繞一個 channel 更直白、更好懂。

鎖的重點是紀律:鎖的範圍要小、要清楚什麼時候鎖什麼時候放、多把鎖的時候小心加鎖順序別造成死結。工具沒有絕對的好壞,用對場景比follow教條重要。

小結

繞了一圈,我對 Go 並發最深的體會,還是回到那個記憶體緩慢上爬的下午教我的事:開 goroutine 很容易,難的是讓它乾淨地結束。 語言把並發做得很順手,順手到你會忘記每一個你開出去的 goroutine,都是一個你要負責讓它善終的東西。那些模式——context、worker pool、select 逾時——說到底都是在幫你回答同一個問題:這東西,最後要怎麼收乾淨。

留言討論

有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。

相關文章