天天看點

踩了 Golang sync.Map 的一個坑

最近 Go 1.15 釋出了,我也第一時間更新了這個版本,畢竟對 Go 的穩定性還是有一些信心的,于是直接在公司上了生産。

結果,上線幾分鐘,就出現了 OOM,于是 pprof 了一下 heap,然後趕緊復原,發現某塊本應該在一次請求結束時被釋放的記憶體,被保留了下來而且一直在增長,如圖(圖中的 linkBufferNode):

踩了 Golang sync.Map 的一個坑

這次上線的變更隻有 Go 版本的更新,沒有任何其它變動,于是在本地開始測試,發現在本地也能百分百複現。

看了 Go 1.15 的 Release Note,發現有倆高度疑似的東西:

去除了一些 GC Data,使得 binary size 減少了 5%;

新的記憶體配置設定算法。

于是改 runtime,關閉新的記憶體配置設定算法,切換回舊的,等等一頓操作猛如虎下來,發現問題還是沒解決,現象仍然存在。

踩了 Golang sync.Map 的一個坑

于是實在不行,祭出了GODEBUG="allocfreetrace=1大法,肉眼從 100MB+ 的日志檔案裡面看啊看啊看啊看啊看啊看啊看啊看啊看啊看啊……(此處省略心酸過程)

最終直覺告訴我,這個問題可能和 Go 1.15 中 sync.Map 的改動有關(别問我為啥,真的是直覺,我也說不出來)。

為了友善講解,我寫了一個最小可複現的代碼,如下:

在 Go 1.15 中,sync.Map 增加了一個方法LoadAndDelete,具體的 issue 在這:https://github.com/golang/go/issues/33762CL, 在這:https://go-review.googlesource.com/c/go/+/205899/。

為什麼我确認是這個改動導緻的呢?很簡單:我在本地把這個改動 revert 掉了,問題就沒了,好了關機下班……

當然沒這麼簡單,知其然要知其是以然,于是開始看到底改了哪塊……(此處省略 100000 字)

最終發現,關鍵代碼是這段:

在這段代碼中,會發現在 Delete 的時候,并沒有真正删除掉 key,而是從 key 中取出了 entry,然後把 entry 設為 nil……

是以,在我們場景中,我們把一個連接配接作為 key 放了進去,于是和這個連接配接相關的比如 buffer 的記憶體就永遠無法釋放了……

那麼為什麼在 Go 1.14 中沒有問題呢?以下是 Go 1.14 的代碼:

在 Go 1.14 中,如果 key 在 dirty 中,是會被删除的;而湊巧,我們其實 “誤用” 了 sync.Map,在我們的使用過程中沒有讀操作,導緻所有的 key 其實都在 dirty 裡面,是以當調用 Delete 的時候是會被真正删除的。

要注意,無論哪個版本的 Go,一旦 key 更新到了 read 中,都是永遠不會被删除的。

在 Go <= 1.15 版本中,sync.Map 中的 key 是不會被删除的,如果在 Key 中放了一個大的對象,或者關聯有記憶體,就會導緻記憶體洩漏。

針對這個問題,我已經向 Go 官方提出了 Issue,目前尚不清楚是 by-design 還是 bug:https://github.com/golang/go/issues/40999