核kmalloc_cache:二維緩存矩陣與SLUB分配機(jī)制)
1. 從一次內(nèi)存泄漏排查說起為什么需要關(guān)注 kmalloc_cache前陣子幫朋友看一個(gè)內(nèi)核模塊的問題現(xiàn)象很典型模塊加載后跑一段時(shí)間/proc/meminfo里的Slab項(xiàng)持續(xù)上漲卸載模塊后也不見回落。用slabtop一看某個(gè)kmalloc-192的緩存對象數(shù)量只增不減。順著這條線索往下挖最后定位到模塊里一處kmalloc之后忘了kfree的分支。問題本身不復(fù)雜但排查過程中我重新把kmalloc_cache這套結(jié)構(gòu)翻了一遍發(fā)現(xiàn)很多平時(shí)寫代碼時(shí)想當(dāng)然的地方其實(shí)背后都有講究。這篇就接著上一部分 SLUB 分配器的內(nèi)容專門聊kmalloc_cache這個(gè)結(jié)構(gòu)。如果你寫過內(nèi)核模塊、調(diào)過驅(qū)動(dòng)、或者單純對 SLUB 怎么管理通用內(nèi)存分配感興趣這篇應(yīng)該能幫你把kmalloc到底從哪兒拿內(nèi)存這件事徹底理清楚。我會(huì)從kmalloc_cache的組織方式講起拆開kmalloc_caches這個(gè)二維數(shù)組說清楚 size index 和 type 是怎么映射到具體緩存的再結(jié)合kmalloc的調(diào)用鏈看看一次分配到底走了哪些路徑。中間會(huì)穿插幾個(gè)我自己踩過的坑比如kmalloc大小和實(shí)際緩存不匹配導(dǎo)致的浪費(fèi)、GFP標(biāo)志選錯(cuò)引發(fā)的連鎖反應(yīng)。先把結(jié)論擺前面kmalloc_cache不是一張簡單的表而是一個(gè)按大小檔位 × 分配類型組織的二維緩存矩陣SLUB 通過它把任意kmalloc請求快速路由到最合適的 slab 緩存上。理解了這個(gè)矩陣的構(gòu)造邏輯很多關(guān)于內(nèi)存開銷、碎片、性能的疑問就都有答案了。2. kmalloc_caches 二維數(shù)組的構(gòu)造邏輯2.1 為什么是二維而不是一維剛接觸這塊的時(shí)候我有個(gè)疑問既然kmalloc是按大小分配搞一個(gè)大小到緩存的一維數(shù)組不就行了為什么要弄成二維后來看代碼才明白多出來的那一維是分配類型kmalloc_type。內(nèi)核里kmalloc的調(diào)用場景差異很大有的是普通內(nèi)核對象分配有的是 DMA 相關(guān)有的要求內(nèi)存可回收reclaimable有的來自cgroup受限上下文。這些場景對內(nèi)存的物理屬性、回收策略、記賬方式要求都不一樣。如果所有請求都擠在同一個(gè)緩存里一個(gè)要求 DMA 能用的分配和一個(gè)普通分配混在一起物理頁屬性就沒法保證。所以 SLUB 的做法是同一檔大小按類型拆成多個(gè)獨(dú)立的緩存。這樣每個(gè)緩存內(nèi)部的 slab 頁屬性一致分配時(shí)不用再做屬性篩選。這就是二維結(jié)構(gòu)的根本原因——第一維是大小檔位第二維是分配類型。用生活化的類比這就像倉庫管理同樣是小件區(qū)但易碎品、危險(xiǎn)品、普通貨物要分開放因?yàn)樗鼈兊拇鎯?chǔ)條件不同。你不能因?yàn)槎际切〖投岩粋€(gè)貨架上。2.2 大小檔位的劃分規(guī)則kmalloc支持的大小不是連續(xù)的而是一組離散檔位。在常見的 64 位配置下檔位大致是這樣排的檔位序號對象大小字節(jié)典型用途08極小結(jié)構(gòu)體、指針數(shù)組116小型描述符232常見小對象364中等小對象496非 2 的冪檔位5128常用檔位6192非 2 的冪檔位7256常用檔位8512中等對象91024較大對象102048大對象114096接近頁大小128192超過單頁這里有個(gè)細(xì)節(jié)值得說檔位里混進(jìn)了 96 和 192 這種非 2 的冪。為什么因?yàn)閷?shí)測發(fā)現(xiàn)內(nèi)核里大量分配請求集中在這些尺寸附近如果只有 64 和 128那么一個(gè) 96 字節(jié)的請求會(huì)被塞進(jìn) 128 的緩存浪費(fèi) 32 字節(jié)而 192 的請求塞進(jìn) 256 會(huì)浪費(fèi) 64 字節(jié)。加上這兩個(gè)檔位后整體內(nèi)存浪費(fèi)明顯下降。這是典型的用一點(diǎn)管理復(fù)雜度換實(shí)際內(nèi)存效率的取舍。注意檔位的具體數(shù)值和內(nèi)核配置有關(guān)CONFIG_SLAB_MERGE_DEFAULT、架構(gòu)位寬、是否開啟CONFIG_RANDOM_KMALLOC_CACHES都會(huì)影響最終結(jié)果??创a時(shí)不要死記數(shù)字要理解劃分思路。2.3 類型維度的實(shí)際含義第二維的類型在代碼里對應(yīng)enum kmalloc_cache_type。常見的有這么幾類KMALLOC_NORMAL普通分配最常用。KMALLOC_RECLAIM可回收分配內(nèi)存緊張時(shí)可以被回收。KMALLOC_DMA要求物理地址連續(xù)且落在 DMA 可尋址范圍。KMALLOC_CGROUP帶 cgroup 內(nèi)存記賬的分配。每種類型對應(yīng)一組獨(dú)立的緩存。也就是說kmalloc-128這個(gè)名字其實(shí)對應(yīng)好幾個(gè)實(shí)際緩存分別服務(wù)于不同場景。你在slabtop里看到的kmalloc-128往往只是其中一類其他類型可能顯示為帶后綴的名字。這里有個(gè)容易踩的坑類型選錯(cuò)不會(huì)報(bào)錯(cuò)但會(huì)帶來隱性代價(jià)。比如你本可以用KMALLOC_NORMAL卻因?yàn)榱?xí)慣寫了GFP_DMA那分配就會(huì)落到 DMA 類型的緩存里。DMA 緩存為了保證可尋址往往預(yù)留更保守長期跑下來內(nèi)存占用會(huì)比普通緩存高。我見過一個(gè)驅(qū)動(dòng)因?yàn)槿逃肎FP_DMA在非 DMA 場景下白白多占了幾十 MB。3. size index 與 type 如何映射到具體緩存3.1 從請求大小到 index 的換算當(dāng)你調(diào)用kmalloc(size, flags)時(shí)第一步是把size換算成檔位序號。這個(gè)換算不是簡單的log2因?yàn)榇嬖?96、192 這種非 2 的冪檔位。內(nèi)核用的是查表加位運(yùn)算結(jié)合的方式。大致邏輯是先判斷size落在哪個(gè)區(qū)間再用預(yù)計(jì)算的映射表拿到 index。對于 2 的冪檔位可以用fls找最高有效位快速定位對于 96、192 這種需要額外判斷。整個(gè)換算過程被設(shè)計(jì)得盡量短因?yàn)閗malloc是熱路徑每次分配都要走一遍。我實(shí)測過一個(gè)細(xì)節(jié)在 x86_64 上kmalloc(100, GFP_KERNEL)會(huì)落到 128 檔位而不是 96。因?yàn)?100 大于 96只能往上取到 128。所以如果你對內(nèi)存敏感把結(jié)構(gòu)體大小壓到剛好不超過某個(gè)檔位能省下可觀的浪費(fèi)。比如把 100 字節(jié)的結(jié)構(gòu)體優(yōu)化到 96 字節(jié)以內(nèi)每個(gè)對象就省 32 字節(jié)十萬個(gè)對象就是 3 MB 多。3.2 type 的判定時(shí)機(jī)類型不是從size推出來的而是從flags里解析出來的。kmalloc內(nèi)部會(huì)檢查flags中的__GFP_DMA、__GFP_RECLAIMABLE等位決定用哪個(gè) type。這里有個(gè)順序問題類型判定在大小換算之前還是之后實(shí)際代碼里兩者是耦合的因?yàn)樽罱K的緩存指針要通過kmalloc_caches[type][index]取。所以流程是先解析 flags 得到 type再把 size 換算成 index最后查二維數(shù)組。偽代碼大概長這樣static __always_inline struct kmem_cache * kmalloc_slab(size_t size, gfp_t flags) { unsigned int index; enum kmalloc_cache_type type; if (size 8) index 0; else if (size 16) index 1; /* ... 中間省略 ... */ else index 12; type kmalloc_type(flags, _RET_IP_); return kmalloc_caches[type][index]; }真實(shí)代碼比這復(fù)雜有KMALLOC_MIN_SIZE處理、有CONFIG_RANDOM_KMALLOC_CACHES的隨機(jī)化但主干邏輯就是這樣。3.3 緩存指針的緩存機(jī)制每次kmalloc都去查二維數(shù)組雖然數(shù)組在內(nèi)存里訪問很快但仍然是熱路徑上的開銷。所以內(nèi)核做了一層優(yōu)化把常用的緩存指針直接內(nèi)聯(lián)到調(diào)用點(diǎn)附近或者用__builtin_constant_p在編譯期就把 size 是常量的情況算好。這就是為什么kmalloc經(jīng)常被寫成宏或者_(dá)_always_inline函數(shù)——編譯器看到kmalloc(64, GFP_KERNEL)這種常量參數(shù)能直接把 index 和 type 都算出來最終變成一次數(shù)組索引加一次 slab 分配調(diào)用。如果你傳的是變量 size那就只能運(yùn)行時(shí)算。實(shí)操建議在性能敏感的代碼里盡量讓kmalloc的 size 是編譯期常量。我做過對比測試常量 size 的分配路徑比變量 size 短一截在高頻分配場景下差距能到百分之幾。4. kmalloc 調(diào)用鏈上的關(guān)鍵分支4.1 小對象走 slab大對象走頁分配kmalloc并不是所有請求都走kmalloc_cache。當(dāng) size 超過最大檔位通常是 8192 或更大取決于配置時(shí)會(huì)直接走頁分配器繞過 slab。這個(gè)分界點(diǎn)很關(guān)鍵。為什么要有這個(gè)分界因?yàn)?slab 的價(jià)值在于把小對象打包進(jìn)頁里減少碎片和管理開銷。當(dāng)對象本身就接近或超過一頁時(shí)打包沒意義了直接分配頁更高效。所以kmalloc內(nèi)部有個(gè)判斷size 大于KMALLOC_MAX_CACHE_SIZE就走alloc_pages。這個(gè)閾值在不同架構(gòu)上不一樣。32 位系統(tǒng)上可能是 819264 位可能更大。寫代碼時(shí)如果依賴這個(gè)邊界一定要用KMALLOC_MAX_CACHE_SIZE宏而不是硬編碼數(shù)字。4.2 分配失敗時(shí)的重試與降級kmalloc在緩存里拿不到對象時(shí)會(huì)觸發(fā) slab 的擴(kuò)容向頁分配器要新頁切成對象掛到 freelist 上。這個(gè)過程涉及GFP標(biāo)志的傳遞。這里有個(gè)我踩過的坑GFP_KERNEL在原子上下文里用會(huì)出問題。因?yàn)镚FP_KERNEL允許睡眠等待內(nèi)存而原子上下文比如中斷處理、持有自旋鎖時(shí)不能睡眠。如果你在中斷里寫了kmalloc(size, GFP_KERNEL)輕則警告重則死鎖。正確做法是用GFP_ATOMIC它不允許睡眠拿不到就返回 NULL。但GFP_ATOMIC也不是隨便用的。它成功率高但代價(jià)是可能動(dòng)用緊急內(nèi)存池長期大量使用會(huì)耗盡預(yù)留內(nèi)存。所以原則是能睡眠的上下文用GFP_KERNEL不能睡眠的才用GFP_ATOMIC且要處理 NULL 返回。4.3 與 per-CPU 緩存的交互SLUB 的高性能很大程度來自 per-CPU 緩存。每個(gè) CPU 有自己的 freelist分配時(shí)優(yōu)先從本地拿避免鎖競爭。kmalloc_cache對應(yīng)的每個(gè) slab 緩存都有自己的 per-CPU 結(jié)構(gòu)。這意味著同一個(gè)kmalloc請求在不同 CPU 上可能拿到不同 slab 頁里的對象。這對大多數(shù)場景沒影響但如果你依賴對象地址的某種局部性比如假設(shè)連續(xù)分配的對象在內(nèi)存上相鄰就會(huì)失望。我見過有人寫代碼假設(shè)兩次kmalloc的地址差是固定值結(jié)果在多核機(jī)器上跑出各種詭異問題。正確的做法是不要對kmalloc返回的地址做任何跨調(diào)用的連續(xù)性假設(shè)。需要連續(xù)內(nèi)存就用kmalloc一次分配一整塊或者用vmalloc。5. 幾個(gè)容易混淆的相鄰概念5.1 kmalloc_cache 與 kmem_cache 的關(guān)系初學(xué)者容易把這兩個(gè)搞混。kmem_cache是 SLUB 里通用的緩存結(jié)構(gòu)任何自定義緩存都是kmem_cache。而kmalloc_cache是專門為kmalloc服務(wù)的那批緩存它們是kmem_cache的特例。關(guān)系上kmalloc_caches[type][index]里存的就是struct kmem_cache *。也就是說kmalloc_cache本質(zhì)上是預(yù)創(chuàng)建好的一批kmem_cache按大小和類型組織成矩陣。你自己調(diào)kmem_cache_create創(chuàng)建的緩存和kmalloc用的緩存在 SLUB 內(nèi)部是同一套機(jī)制管理的。理解這一點(diǎn)后很多行為就通了kmalloc的緩存也會(huì)出現(xiàn)在/proc/slabinfo里也會(huì)被slabtop統(tǒng)計(jì)因?yàn)樗鼈兙褪瞧胀ǖ?slab 緩存。5.2 kmalloc 與 kzalloc、kcalloc 的區(qū)別這三個(gè)經(jīng)常一起出現(xiàn)區(qū)別在初始化kmalloc只分配不初始化內(nèi)存里是臟數(shù)據(jù)。kzalloc分配并清零。kcalloc分配數(shù)組并清零內(nèi)部會(huì)做乘法溢出檢查。從kmalloc_cache的角度看它們走的是同一套緩存區(qū)別只在分配后是否memset。所以kzalloc的額外開銷就是一次清零。對于大對象清零成本不可忽略對于小對象現(xiàn)代 CPU 的memset很快通常不是瓶頸。經(jīng)驗(yàn)如果對象馬上會(huì)被完整覆蓋用kmalloc就行別習(xí)慣性寫kzalloc。我在一個(gè)高頻路徑上把kzalloc改成kmalloc省下的清零時(shí)間在壓測里能看出來。5.3 與 vmalloc 的邊界kmalloc保證物理連續(xù)vmalloc只保證虛擬連續(xù)。kmalloc_cache管的是前者。當(dāng)你要的 size 很大、物理連續(xù)內(nèi)存難找時(shí)kmalloc會(huì)失敗這時(shí)候才考慮vmalloc。但vmalloc有代價(jià)需要建立頁表映射TLB 壓力大訪問速度可能不如kmalloc的內(nèi)存。所以原則是能用kmalloc就用kmalloc實(shí)在要不到連續(xù)物理頁再用vmalloc。不要因?yàn)関malloc能分配更大就無腦選它。6. 排查與觀測怎么看清 kmalloc_cache 的實(shí)際狀態(tài)6.1 用 slabinfo 看緩存分布/proc/slabinfo是最直接的窗口。里面每一行是一個(gè)緩存字段包括對象大小、活躍對象數(shù)、總對象數(shù)等。關(guān)注kmalloc-*開頭的行能看出各檔位的使用情況。我排查內(nèi)存泄漏時(shí)的套路是隔一段時(shí)間抓兩次slabinfo對比active_objs的變化。如果某個(gè)kmalloc-*的活躍對象持續(xù)上漲且不回落基本就是泄漏。再結(jié)合代碼里該檔位對應(yīng)的分配點(diǎn)范圍就縮小了。6.2 slabtop 的實(shí)時(shí)視角slabtop是slabinfo的動(dòng)態(tài)版按對象數(shù)或內(nèi)存占用排序。它的好處是能一眼看到誰在吃內(nèi)存。我一般按Slab大小排序看排前面的幾個(gè)緩存是不是符合預(yù)期。有個(gè)細(xì)節(jié)slabtop顯示的kmalloc-*可能不包含所有類型。因?yàn)轭愋途S度的緩存名字可能帶后綴或者被合并顯示。要精確統(tǒng)計(jì)還是得看slabinfo原始數(shù)據(jù)。6.3 用 tracepoint 追蹤單次分配如果泄漏點(diǎn)很隱蔽靜態(tài)統(tǒng)計(jì)不夠用可以上 tracepoint。kmalloc相關(guān)的 tracepoint 能記錄每次分配的調(diào)用地址、size、返回指針。配合perf或ftrace能精確定位到是哪行代碼在反復(fù)分配。這個(gè)手段比較重一般只在前面幾步定位不到時(shí)才用。但它確實(shí)有效——我有個(gè)同事就是靠 tracepoint 抓到一個(gè)在錯(cuò)誤分支里反復(fù)kmalloc卻沒釋放的 bug。7. 寫代碼時(shí)的幾條實(shí)用原則7.1 讓 size 貼近檔位上限前面提過kmalloc按檔位分配實(shí)際占用是檔位大小而非請求大小。所以結(jié)構(gòu)體設(shè)計(jì)時(shí)盡量讓sizeof落在檔位內(nèi)且接近上限。比如你的結(jié)構(gòu)體是 130 字節(jié)會(huì)落到 192 檔位浪費(fèi) 62 字節(jié)如果能壓到 128 以內(nèi)就省下 64 字節(jié)。壓縮手段包括調(diào)整字段順序減少 padding、用位域、把不常用的字段拆出去單獨(dú)分配。這些優(yōu)化在對象數(shù)量大時(shí)收益明顯。7.2 flags 要精確不要圖省事全用GFP_KERNEL或全用GFP_ATOMIC。根據(jù)上下文選上下文推薦 flags說明進(jìn)程上下文可睡眠GFP_KERNEL最常用中斷/軟中斷GFP_ATOMIC不可睡眠持有自旋鎖GFP_ATOMIC不可睡眠DMA 場景GFP_KERNEL | GFP_DMA按需加 DMA可回收對象GFP_KERNEL | __GFP_RECLAIMABLE內(nèi)存緊張時(shí)可回收選錯(cuò) flags 的代價(jià)前面說過要么死鎖要么浪費(fèi)內(nèi)存要么記賬錯(cuò)誤。7.3 釋放要及時(shí)且路徑唯一kmalloc的配對kfree一定要在所有路徑上都覆蓋。我見過太多 bug 是正常路徑釋放了錯(cuò)誤路徑忘了。建議用goto統(tǒng)一錯(cuò)誤處理或者用__free之類的清理屬性新內(nèi)核支持減少遺漏。小技巧如果編譯器支持__attribute__((cleanup))可以封裝一個(gè)自動(dòng)釋放的包裝讓kfree在作用域結(jié)束時(shí)自動(dòng)執(zhí)行。這在復(fù)雜函數(shù)里能顯著降低泄漏風(fēng)險(xiǎn)。7.4 別對地址做連續(xù)性假設(shè)前面強(qiáng)調(diào)過per-CPU 緩存讓分配地址不可預(yù)測。任何依賴兩次分配相鄰的代碼都是脆弱的。需要連續(xù)內(nèi)存就一次分配夠或者用專門的數(shù)據(jù)結(jié)構(gòu)。8. 一個(gè)完整的觀測實(shí)例假設(shè)你懷疑某個(gè)模塊有kmalloc泄漏可以按這個(gè)流程走加載模塊記錄初始slabinfo中相關(guān)kmalloc-*的active_objs。跑業(yè)務(wù)邏輯觸發(fā)模塊的主要路徑。再次抓取slabinfo對比active_objs。如果某檔位持續(xù)上漲用slabtop確認(rèn)再用 tracepoint 定位具體調(diào)用點(diǎn)。找到分配點(diǎn)后檢查所有分支的釋放路徑。這個(gè)流程我在多個(gè)項(xiàng)目里用過基本能覆蓋大部分kmalloc相關(guān)問題。關(guān)鍵是要有基線對比的意識(shí)單次快照說明不了問題。9. 關(guān)于 kmalloc_cache 的一點(diǎn)個(gè)人體會(huì)把kmalloc_cache這套結(jié)構(gòu)吃透之后我寫內(nèi)核代碼時(shí)對內(nèi)存的手感明顯好了。以前kmalloc就是隨手一寫現(xiàn)在會(huì)下意識(shí)想這個(gè) size 會(huì)落到哪個(gè)檔位flags 選對了嗎釋放路徑全不全這些思考不費(fèi)多少時(shí)間但能避免很多后期排查的麻煩。SLUB 的設(shè)計(jì)哲學(xué)其實(shí)很樸素用結(jié)構(gòu)化的緩存矩陣把千變?nèi)f化的分配請求收斂到有限的管理單元上。kmalloc_cache就是這個(gè)哲學(xué)的集中體現(xiàn)。理解了它再去看kmem_cache_create、kmem_cache_alloc這些接口會(huì)發(fā)現(xiàn)它們和kmalloc是同一套邏輯的不同入口。最后分享一個(gè)我常用的檢查習(xí)慣每次寫完涉及kmalloc的模塊加載后先跑一遍slabtop看看有沒有意外的緩存增長。這個(gè)動(dòng)作花不了幾秒但經(jīng)常能在早期發(fā)現(xiàn)苗頭。內(nèi)存問題越早發(fā)現(xiàn)越好查等到線上跑出問題時(shí)現(xiàn)場往往已經(jīng)很難復(fù)現(xiàn)了。