內(nèi)存泄漏:分頁緩沖池與非分頁緩沖池同步上漲的句柄泄漏排查)
1. 事故第一現(xiàn)場內(nèi)存只漲不降而且漲的是緩沖池1.1 凌晨收到的那條告警GODService是我們內(nèi)部一套用C寫的常駐后臺服務(wù)負責(zé)業(yè)務(wù)事件的分發(fā)和狀態(tài)同步部署形態(tài)是每臺機器一個實例7x24小時跑。它平時很穩(wěn)CPU占用常年個位數(shù)內(nèi)存也就是啟動后穩(wěn)定在200MB左右。所以當(dāng)凌晨監(jiān)控彈出“可用內(nèi)存持續(xù)下降GODService進程內(nèi)存連續(xù)72小時只升不回”的時候運維第一反應(yīng)是“是不是某條業(yè)務(wù)死循環(huán)了”。我登錄上去看了一眼任務(wù)管理器確實不對勁。進程的“內(nèi)存(活動)”值已經(jīng)漲到1.6GB而且每分鐘都在跳。更扎眼的是下面的內(nèi)存區(qū)域——分頁緩沖池和非分頁緩沖池兩個數(shù)字都在同步爬升非分頁緩沖池尤其夸張從正常的300MB左右一路漲到了接近1.2GB。這里先插一句基礎(chǔ)知識Windows內(nèi)核里內(nèi)核態(tài)和驅(qū)動申請的內(nèi)存分成兩檔一個是分頁緩沖池可以被換出到磁盤另一個是非分頁緩沖池任何時候都必須留在物理內(nèi)存里供中斷、DPC這些不能等磁盤I/O的路徑使用。任務(wù)管理器里那兩項指標(biāo)顯示的就是這兩類內(nèi)核態(tài)內(nèi)存的總用量。很多人第一反應(yīng)是“GODService是不是申請了很大一塊堆沒釋放”但堆內(nèi)存大部分是用戶態(tài)的私有字節(jié)不會直接反映在分頁和非分頁緩沖池里??吹絻蓚€緩沖池同時漲方向就完全變了——這更像是內(nèi)核對象、句柄這類資源在持續(xù)堆積。這不是堆的問題是資源句柄的問題。1.2 為什么我第一反應(yīng)不是“堆泄漏”在有GC機制的環(huán)境里呆久的人看到一個進程內(nèi)存漲第一個念頭會去看托管堆有沒有回收但對于C寫的常駐服務(wù)內(nèi)存漲的原因是很多的堆碎片、內(nèi)存池膨脹、緩存無上限、句柄泄漏、線程對象殘留每一樣的表現(xiàn)都不一樣。我能快速排除“堆泄漏”靠的是一個細節(jié)分頁緩沖池和非分頁緩沖池是內(nèi)核態(tài)的概念GODService作為一個普通用戶態(tài)進程它自己幾乎不可能直接往這兩個池里分配內(nèi)存。它能影響這兩個池的途徑只有一個——不斷創(chuàng)建內(nèi)核對象、不斷往進程句柄表里塞東西。每建一次Event、File、Mutex、Semaphore對象管理器就要在非分頁池里放對象頭在分頁池里維護句柄表條目。句柄越多兩個池漲得越兇。所以當(dāng)時的判斷是這不是內(nèi)存“變大”的問題而是某段代碼在不停“造東西”但沒銷毀。常見的嫌疑是事件對象、文件句柄、注冊表鍵、線程內(nèi)核對象、GDI/USER對象這幾類。接下來要做的不是翻代碼而是先把“是哪一類句柄”確認下來。1.3 分頁緩沖池與非分頁緩沖池在Win11上怎么看順帶說下工具問題。Win11的任務(wù)管理器改版之后性能頁面的內(nèi)存詳情變得很簡潔“分頁緩沖池/非分頁緩沖池”這兩個字段藏在很隱蔽的位置普通用戶基本看不到。我平時更習(xí)慣用性能監(jiān)視器perfmon.msc直接加計數(shù)器或者用Sysinternals RAMMap看“Pool”標(biāo)簽頁。要用到的核心計數(shù)器就四個計數(shù)器作用\Process(GODService*)\Handle Count看進程句柄數(shù)是否只漲不降\Process(GODService*)\Private Bytes看用戶態(tài)堆內(nèi)存增長\Memory\Pool Nonpaged Bytes看非分頁緩沖池總量\Memory\Pool Paged Bytes看分頁緩沖池總量注意前兩個是進程維度的后兩個是系統(tǒng)維度的。系統(tǒng)維度計數(shù)器會上漲的不只是我們這一個進程但如果全機器其他進程都安靜就它一個在瘋狂刷句柄那么系統(tǒng)級池數(shù)值也能反推出來。2. 縮小包圍圈句柄數(shù)和緩沖池一起爬升2.1 三個計數(shù)器的走勢圖說明了一切我在監(jiān)控機上拉了兩天的歷史曲線做了個對照GODService的Private Bytes漲得不算快但從啟動開始就沒有任何回落的跡象而Handle Count從啟動時的2000左右一路漲到接近15萬幾乎是一條筆直的上升線。再疊加系統(tǒng)級的Pool Nonpaged Bytes三根線在時間維度上完全同步。這個現(xiàn)象基本能鎖定方向句柄泄漏。因為如果是單純的內(nèi)存泄漏Private Bytes會有一個很陡的上升趨勢而Handle Count不會同步變化現(xiàn)在句柄數(shù)同步上天說明泄漏的粒度和“每次循環(huán)/每次請求造一個對象”高度相關(guān)。這里有個判斷口訣我用了很多年內(nèi)存漲句柄不漲優(yōu)先查堆和緩存內(nèi)存漲句柄也漲基本就是資源沒釋放。反過來句柄漲但內(nèi)存不漲其實更危險因為它量級更隱蔽等你發(fā)現(xiàn)時可能已經(jīng)逼近系統(tǒng)上限了。2.2 句柄表為什么會同時壓到兩個緩沖池很多人不理解為什么GODService一個用戶態(tài)進程的句柄泄漏能讓W(xué)in11的“分頁緩沖池”和“非分頁緩沖池”同時上漲。這要回到Windows對象管理器的工作方式。進程創(chuàng)建第一個內(nèi)核對象時系統(tǒng)會為它建立一張句柄表。句柄表本身是分頁的里面存放句柄條目比如對象指針、訪問掩碼、屬性標(biāo)記這些數(shù)據(jù)落在分頁緩沖池里。而句柄對應(yīng)的內(nèi)核對象本體比如一個Event對象它的對象頭和核心數(shù)據(jù)結(jié)構(gòu)是需要常駐物理內(nèi)存的因為它們要參與等待調(diào)度和事件通知不能被換出——所以從非分頁緩沖池分配。每次CreateEvent成功等于同時向分頁池和非分頁池各借了一塊地。CloseHandle就是還地。如果你光借不還兩個池都會漲。所以GODService在Win11上表現(xiàn)出來的“分頁緩沖池和非分頁緩沖池內(nèi)存泄漏”本質(zhì)上是同一個句柄泄漏的一體兩面。2.3 當(dāng)時做的兩個排除試驗鎖定方向后我做了兩個快速排除試驗?zāi)康氖谴_認不是別的東西在搗亂。第一個試驗是把Process Explorer里的列調(diào)出來加了User Objects、GDI Objects、Handles三列觀察GODService的GDI對象和用戶對象是否同時增長。結(jié)果GDI Objects穩(wěn)定在一個值附近User Objects也只有小幅波動只有Handles在飛速上漲。這說明問題出在內(nèi)核對象句柄不是GUI相關(guān)資源。第二個試驗更狠一點我在一臺測試機上直接把GODService進程殺掉再重啟。如果是堆緩存膨脹重啟后內(nèi)存會回落到正常水平但運行一段時間后又會慢慢漲如果是文件映射或者磁盤緩存問題DropCache之后可能會緩解。而試驗結(jié)果是重啟后句柄數(shù)回到基線運行一個小時后又開始一路向上斜率基本和之前一致。這說明代碼路徑是每次運行時都在反復(fù)觸發(fā)不是偶發(fā)性的。到這里問題已經(jīng)很明確GODService進程內(nèi)部存在規(guī)律性的句柄創(chuàng)建并且沒有對應(yīng)釋放。接下來要找出是哪一行代碼。3. 用windbg的htrace把泄漏代碼揪出來3.1 為什么選htrace而不是直接翻代碼團隊里有人提了個建議直接用代碼平臺搜索看一下有沒有忘了CloseHandle的地方。這思路沒錯但GODService是一個幾萬行代碼的工程涉及插件通信、超時調(diào)度、文件監(jiān)聽、網(wǎng)絡(luò)連接池手動掃一遍少說也得大半天而且像句柄泄漏這種問題單看代碼不一定看得出來——因為“這塊代碼看著有釋放其實在一個異常分支里繞過去了”的情況太常見了。我更推薦動態(tài)追蹤直接問系統(tǒng)哪些句柄被創(chuàng)建了創(chuàng)建時的調(diào)用棧是什么最后有沒有被關(guān)閉。Windows調(diào)試工具的!htrace命令就是干這個的。它會記錄進程里句柄操作的調(diào)用棧通過兩次快照對比能精確列出“這個時間段內(nèi)創(chuàng)建但沒關(guān)閉的句柄清單”。3.2 完整操作路徑用任務(wù)管理器轉(zhuǎn)儲之類的方法都不夠我當(dāng)時的操作是這樣的你可以在自己的環(huán)境里復(fù)現(xiàn)!gflag htrace !htrace -enable這兩條命令在windbg里對live進程執(zhí)行即可。!gflag htrace是打開當(dāng)前進程的句柄追蹤標(biāo)志!htrace -enable開始記錄句柄操作。需要注意開啟追蹤是有性能開銷的所以不要在全部生產(chǎn)機上都開最好找一臺流量較小的灰度機確認問題存在但不會影響業(yè)務(wù)。等GODService運行一段時間比如15分鐘或者一個小時先做第一次快照!htrace -snap快照做完之后再等一段時間讓泄漏繼續(xù)累積。然后再做第二次快照!htrace -snap !htrace -diff!htrace -diff會把兩次快照之間新增的句柄操作列出來包含創(chuàng)建句柄的完整調(diào)用棧??吹降木褪沁@段窗口期內(nèi)創(chuàng)建的、尚未關(guān)閉的句柄指紋。3.3 快照對比中看到的調(diào)用棧diff的結(jié)果出來時數(shù)據(jù)非常明顯。新句柄幾乎都集中在一個模塊里調(diào)用棧反復(fù)出現(xiàn)這樣的路徑kernel32!CreateEventW godservice!CFsmTimer::CheckPluginHeartbeat godservice!CWorkerThread::ProcessTimeoutQueue也就是說每一次心跳檢測超時代碼都會創(chuàng)建一個Event然后這個Event沒有在等待結(jié)束后被關(guān)掉。反過來看那些正常的執(zhí)行路徑在信號正常返回的case下是有CloseHandle的只有超時分支漏了。這正好解釋了為什么監(jiān)測期間GPK包越多、超時越頻繁內(nèi)存漲得越快半夜業(yè)務(wù)低峰超時少但第二天高峰一到又一輪快速爬升。我后來還在dumps里用!handle掃過進程句柄列表看到大量類型為Event的句柄對象名基本都是空的確認無誤。4. 根因事件句柄在超時分支上沒關(guān)4.1 觸發(fā)場景和當(dāng)時的代碼寫法把調(diào)用棧對應(yīng)回代碼問題很快就暴露了。GODService里有一條插件心跳檢查邏輯負責(zé)定期確認外部組件的存活狀態(tài)。組件的心跳信號是動態(tài)注冊的一次性通知所以代碼不能直接復(fù)用一個全局事件只能每次檢測時創(chuàng)建一個臨時事件對象等插件上報或者超時然后釋放。下面是精簡過的原始寫法相信不少做過Windows服務(wù)開發(fā)的人看一眼就會有共鳴void CWorkerThread::CheckPluginHeartbeat() { HANDLE hEvent ::CreateEventW(nullptr, TRUE, FALSE, nullptr); if (hEvent nullptr) { return; } DWORD dwWait ::WaitForSingleObject(hEvent, 3000); if (dwWait WAIT_TIMEOUT) { // 插件沒在3秒內(nèi)上報心跳跳過本次檢查 continue; // 問題就出在這里句柄沒有關(guān)閉 } if (dwWait WAIT_OBJECT_0) { ::ResetEvent(hEvent); // 這里處理插件上報的數(shù)據(jù) } ::CloseHandle(hEvent); }這段代碼寫的人當(dāng)時應(yīng)該也認真考慮過資源釋放因為正常分支和信號到達分支都有關(guān)閉操作唯獨超時分支里寫了個continue提前跳出。手一抖一個CloseHandle就漏掉了。4.2 修復(fù)前的資源生命周期分析我后來又把問題代碼的運行路徑完整捋了一遍發(fā)現(xiàn)這個泄漏的放大效應(yīng)比單看代碼更嚴(yán)重。正常邏輯下插件如果在3秒內(nèi)上報心跳WaitForSingleObject返回WAIT_OBJECT_0代碼會走處理流程然后CloseHandle萬事大吉。但一旦插件響應(yīng)慢或者網(wǎng)絡(luò)抖動WaitForSingleObject在3秒后返回WAIT_TIMEOUT代碼立刻continue跳到循環(huán)的下一次迭代重新CreateEvent。每次超時都創(chuàng)建一個新Event對象但舊對象永遠留在句柄表里。這個場景有多頻繁呢心跳檢查周期是5秒而GODService同時管理幾十個插件維度每個維度都要做一次檢查。如果一個周期里有10個插件超時一分鐘就是120個泄漏句柄一天就是17萬左右。你想象一下任何一個Windows常駐服務(wù)如果每天往非分頁池里塞十幾萬個Event對象內(nèi)存不炸才怪。而且這種泄漏還有個隱蔽性它平時不會立刻暴露服務(wù)剛啟動的前幾小時一切正常內(nèi)存增長也很平緩??芍灰獦I(yè)務(wù)波動一次、超時頻率上來斜率立刻變陡。因為和業(yè)務(wù)高峰期強相關(guān)很容易被誤讀成“業(yè)務(wù)量大了內(nèi)存占用高正常”這也是很多內(nèi)存泄漏線報一直沒被重視的原因。4.3 修復(fù)后的正確寫法修復(fù)本身并不復(fù)雜重點不是補一行CloseHandle而是改變資源管理的習(xí)慣。我的修復(fù)版本長這樣void CWorkerThread::CheckPluginHeartbeat() { wil::unique_handle hEvent(::CreateEventW(nullptr, TRUE, FALSE, nullptr)); if (!hEvent) { return; } DWORD dwWait ::WaitForSingleObject(hEvent.get(), 3000); if (dwWait WAIT_TIMEOUT) { // 插件沒心跳跳過本次資源由 RAII 自動釋放 continue; } if (dwWait WAIT_OBJECT_0) { ::ResetEvent(hEvent.get()); // 正常處理 } }核心改動是引入了RAII封裝讓句柄的生命周期跟著局部作用域走。無論函數(shù)從哪里return無論是continue還是break還是異常只要出了作用域析構(gòu)函數(shù)會統(tǒng)一調(diào)用CloseHandle。這是C資源管理最樸素也最有效的方案。如果你用的是正規(guī)Windows C開發(fā)環(huán)境微軟提供的wil庫可以直接用wil::unique_handle不想引庫的話自己封裝一個SafeHandle模板類也沒問題。總之原則只有一條誰創(chuàng)建誰負責(zé)并且要在所有出口都保證釋放。順帶建議這類事件檢測邏輯其實也沒必要每次循環(huán)都新建Event更好的設(shè)計是復(fù)用一個常駐信號對象配合ResetEvent和WaitForSingleObject這樣連“反復(fù)創(chuàng)建”這個成本都省了。不過這是事后重構(gòu)話題了先把泄漏堵住才是首要任務(wù)。5. 回歸驗證與上線策略觀察哪幾個指標(biāo)5.1 灰度期間的對照表格修復(fù)完成后我沒有直接全量發(fā)布而是選了兩臺機器做灰度對照一臺保留舊版本繼續(xù)跑一臺部署新版本。然后對比24小時和48小時的關(guān)鍵指標(biāo)。下面這個表是灰度測試后的實際數(shù)據(jù)整理指標(biāo)舊版本修復(fù)前48h新版本修復(fù)后48h進程句柄數(shù)穩(wěn)定上升約每秒新增2-3個啟動后爬升到3000左右然后回落穩(wěn)定Private Bytes以約200MB/天的速度增長基本穩(wěn)定波動幅度在幾十MB以內(nèi)Pool Nonpaged Bytes線性上漲48小時增加約660MB小范圍波動無持續(xù)上漲趨勢Pool Paged Bytes同步上漲略有波動無異常爬升插件處理隊列周期性堆積無堆積延遲恢復(fù)正常最直觀的差異就是句柄數(shù)。舊版本是新版本一晚漲好幾萬始終不回落新版本啟動時會有一個正常預(yù)熱的過程句柄數(shù)漲到幾千后開始出現(xiàn)明顯的鋸齒形——上漲一批、釋放一批、再上漲??吹戒忼X形就說明釋放動作已經(jīng)恢復(fù)了。5.2 “先漲后穩(wěn)”是健康信號這里想單獨說一個觀察結(jié)論修復(fù)后的服務(wù)啟動初期內(nèi)存和句柄數(shù)會有一個快速上升的階段不懂的人可能會誤以為“還在泄漏”。其實這是正常的預(yù)熱過程。GODService啟動時要建立插件連接池、讀取配置、注冊各種通知事件這些對象都是需要創(chuàng)建的。只要上升曲線到達一個平臺后開始震蕩而不是繼續(xù)走高就是健康的。判斷泄漏與否關(guān)鍵不在絕對值大小而在斜率有沒有歸零。我在灰度測試時專門看了第二天和第三天的數(shù)據(jù)句柄數(shù)始終維持在同一區(qū)間上下波動沒有出現(xiàn)第二天比第一天高一截、第三天再高一截的情況。Pool Nonpaged Bytes也穩(wěn)定在固定區(qū)間。到這里才能說修復(fù)有效。5.3 回滾預(yù)案和長穩(wěn)驗證因為GODService涉及業(yè)務(wù)狀態(tài)同步我還定了兩個保底措施。第一灰度機保留舊版本鏡像一旦新版本出現(xiàn)異??梢园胄r內(nèi)切回第二把修復(fù)版本壓在一個模擬仿真環(huán)境里連續(xù)跑七天模擬高峰流量和插件大量超時的場景。長穩(wěn)驗證期間我額外加了一個自動化監(jiān)控腳本用的是Windows自帶的Performance Counter好處是不用裝額外agentGet-Counter ( \Process(GODService*)\Handle Count, \Process(GODService*)\Private Bytes, \Memory\Pool Nonpaged Bytes, \Memory\Pool Paged Bytes ) -SampleInterval 30 -MaxSamples 100腳本每30秒采集一次連續(xù)采集100次輸出到CSV后直接用Excel畫趨勢圖一眼就能看出有沒有新的上漲斜率。這套腳本我現(xiàn)在還留在團隊的運維工具箱里后來排查別的問題也反復(fù)用到。6. 復(fù)盤Windows常駐服務(wù)還有哪些池泄漏偽裝術(shù)6.1 容易被誤判的幾種池泄漏GODService這次是Event句柄泄漏但Windows常駐服務(wù)里的池泄漏還遠不止這一種。我借這個機會把過去踩過的坑一起復(fù)盤一下方便你在排查時少走彎路。第一種是GDI/USER對象泄漏。這類泄漏在任務(wù)管理器里通常表現(xiàn)為分頁緩沖池上漲但Handle Count不一定漲得兇因為GDI句柄走的是另一套句柄表。排查工具要用Process Explorer單獨看GDI Objects列或者用!gditable命令檢查GDI句柄表。第二種是線程對象泄漏。每次創(chuàng)建線程系統(tǒng)都會創(chuàng)建線程內(nèi)核對象保護結(jié)構(gòu)落在非分頁緩沖池。如果你發(fā)現(xiàn)非分頁池上漲但句柄數(shù)穩(wěn)定可以數(shù)一下進程里的線程數(shù)。很多隱藏的線程池泄漏就是這樣代碼里_beginthreadex開了線程卻沒有在所有出口都做WaitForSingleObject和CloseHandle。第三種是Timer隊列泄漏。Windows的Timer Queue Timer創(chuàng)建后沒有調(diào)用DeleteTimerQueueEx或者每次CreateThreadPoolTimer都新分配一個上下文也會造成分頁池上漲。這類問題在代碼靜態(tài)掃描里經(jīng)常發(fā)現(xiàn)不了因為創(chuàng)建和釋放可能分布在不同的類里。還有一種最容易搞的烏龍是.NET/P/Invoke場景。GODService雖然是C但團隊里還有其他語言寫的Windows服務(wù)。用C#調(diào)Win32 API時如果返回的IntPtr沒有包裝成SafeHandleGC是不知道你需要釋放這個句柄的。結(jié)果是托管堆很干凈非分頁池卻一路狂飆。排查這類問題務(wù)必先看有沒有SafeHandle再看有沒有漏掉的CloseHandle。6.2 把句柄審計做成日常能力修復(fù)GODService用到的這一整套排查鏈路其實可以沉淀成團隊日常能力。我現(xiàn)在到一個新環(huán)境處理Windows常駐服務(wù)內(nèi)存問題的固定套路是先看三件事Handle Count、GDI Objects、線程數(shù)。這三項定性快60秒內(nèi)就能判斷是不是資源泄漏。再看系統(tǒng)級內(nèi)存Pool Nonpaged Bytes和Pool Paged Bytes重點觀察斜率而不是絕對值。最后用windbg的!htrace或其他窮舉工具抓調(diào)用棧定位具體代碼位置。同時強烈建議把“進程句柄數(shù)”加入監(jiān)控告警項。內(nèi)存使用率可以靠物理內(nèi)存和業(yè)務(wù)負載兜底但句柄數(shù)是硬指標(biāo)Windows對每個進程的句柄數(shù)限制是明確的一旦接近上限不只是內(nèi)存爆掉的問題整個服務(wù)都會處于卡死狀態(tài)。6.3 這次踩完坑我留下的三條硬規(guī)矩第一所有涉及內(nèi)核對象的代碼一律不寫裸句柄。要么用RAII封裝要么用SafeHandle嚴(yán)禁在業(yè)務(wù)函數(shù)里直接傳遞HANDLE變量。這次事件證明人的記憶會出錯但作用域邊界不會。第二超時分支是資源管理的重災(zāi)區(qū)。不管是CreateEvent、CreateFile還是WaitForSingleObject凡是帶超時參數(shù)的邏輯寫完之后就要立刻檢查超時返回路徑上有沒有留下未釋放的資源??梢园堰@段檢查當(dāng)成和空指針判斷一樣的必備動作時間久了就養(yǎng)成肌肉記憶。第三定期拿生產(chǎn)環(huán)境的一個副本開啟htrace跑一遍。不需要每個版本都跑但任何涉及線程、事件、信號量、定時器的大版本變更都應(yīng)該做一次句柄級回歸。成本不高卻能避免內(nèi)存泄漏這種慢性病拖到線上暴發(fā)。這次GODService的修復(fù)報告寫到最后我最想說的一句話是Windows服務(wù)的“內(nèi)存泄漏”四個字很多時候是表象真正的病灶藏在句柄、線程和內(nèi)核對象里。多看一眼分頁緩沖池和非分頁緩沖池的斜率比盯著任務(wù)管理器里那個不斷翻新的MB數(shù)字要靠譜得多。至少對這個服務(wù)來說從“看到緩沖池漲”到“找到那一行continue”中間只隔了一條!htrace -diff的距離。