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