概述:
又到了一個總結提煉的階段,這次想具體聊聊遊戲引擎中使用的內存管理模塊tcmalloc組件的使用心得。項目的前期曾經遇到過內存瓶頸,特別是windows系統下的客戶端程序在經歷長時間運行之後會出現內存佔用率很高疑似泄漏的現象,排查了很久都沒有找到原因,甚至一度無法定位問題出自遊戲腳本層還是引擎層,後來在引擎中鏈接了tcmalloc組件,通過實時dump程序的內存信息最終找出了泄漏的元兇。tcmalloc的另一個優勢就是通過高效率內存分配來提高遊戲運行時性能,不得不說在使用tcmalloc之後,整個遊戲的穩定性和效率都有了很大的提升。爲了今後更有效和穩定地使用tcmalloc組件,就在這裏深入剖析一下這個神器。Tcmalloc是Google Perftools中的一個組件,提供一整套高效健壯的內存管理方案,比傳統glibc的內存分配和釋放要快數倍;其次,基於tcmalloc之上的heapprofiler可以實時dump程序中heap的使用信息,是一個很好的檢測內存泄漏的輔助工具;同時tcmalloc的使用又是極其方便,只需要在編譯時增加一個鏈接選項,就可以無縫攔截(patch)原生操作系統運行庫中的內存分配和釋放接口,而無需修改已經完成的項目工程代碼,大大減少移植整合的成本。
在windows平臺下,tcmalloc可以通過靜態庫或者動態庫(DLL)的形式嵌入到工程裏面,這裏將主要 分析tcmalloc如何DLL動態鏈接到工程裏面,同時將重點剖析一下tcmalloc如何在不改變工程原有代碼的前提下無縫地攔截windows原生內存管理接口。
配置步驟:
以DLL形式鏈接進入工程的主要步驟如下:首先從官網下載並解壓gperftools包,下載地址爲:http://code.google.com/p/gperftools/downloads/list,現有的版本是2.1;打開並編譯gperftools-2.1目錄下的gperftools.sln;編譯通過後,在build輸出目錄下生成libtcmalloc_minimal.dll和對應的lib文件;將lib和dll文件拷貝到工程編譯目錄下,並在鏈接選項中添加兩個配置,如下圖所示:additional dependencies(附加依賴項): libtcmalloc_minimal.dll; force symbol references(強制符號引用):__tcmalloc (64bit 系統);重新編譯鏈接後,exe運行時tcmalloc將在程序靜態變量初始化階段攔截所有原生內存管理接口。
無縫鏈接原理剖析:
要理解tcmalloc如何無縫攔截底層運行庫(runtime library)中的內存管理函數,首先需要理解windows平臺下的可執行文件和 exe加載流程。windows平臺下的可執行文件是以PE(Portable Executable)格式存在的,由各個不同的段組織而成,如.data .text .rsrc等,其中.text段包含了模塊內所有代碼的二進制輸出,相應的函數調用是以
call XXXXXXXX
的彙編指令存在,其中XXXXXXXX表示程序運行時的函數虛擬地址。由於本模塊PE文件各個段的佈局和相應加載地址是在鏈接時決定,所以對於本模塊內的函數調用可以在鏈接時就計算得到相應的函數地址,如下圖所示,對某.text段中的.func函數進行調用,.func函數地址XXXXXXX可以通過將該段的加載地址和函數在段內的偏移兩者相加得到:
對於隱式(implicit)鏈接的DLL模塊,由於鏈接器無法在鏈接階段得到DLL模塊中各個段的佈局和加載信息,所以無法直接計算得到具體函數地址。如果其他模塊需要調用DLL內的函數,PE文件通過一種稱爲引用地址數據表(Import Address Table, IAT)的數據結構間接指向這些函數,在鏈接階段鏈接器簡單在IAT中寫入各個函數的symbol,而相應的call指令也變成了如下形式:
CALL DWORD PTR [XXXXXXXX]
其中[XXXXXXXX]表示.func函數在IAT中相應slot的地址,如下圖所示,XXXXXXXX值是由IAT表的加載地址和.func的slot index兩者相加得到的:
當這個可執行文件加載運行時,windows的程序加載器(Loader)負責解析這個PE文件格式,將文件中的各個數據段和代碼段映射到進程的地址空間,同時通過遍歷IMAGE_IMPORT_DESCRIPTOR 段,將所有隱式鏈接的DLL都加載到內存中,同時更新各個IAT中的slot,寫入symbol所對應函數所在的內存地址,這樣就保證了指令CALL DWORD PTR [XXXXXXXX]可以正確地調用到其他模塊中的函數。
內存管理模塊一般由操作系統底層運行庫(runtime library)或第三方庫提供,以動態或者靜態的方式鏈接入可執行文件,攔截這些函數的方法一般有兩種:
1)對需要攔截的內存管理函數,修改所有本地對其call指令的目的地址和IAT slot中可能引用到的間接函數地址,將它們指向新的替換函數地址,如下圖A所示:
圖A,main module是可執行文件,module B是底層運行庫或者實現了內存管理的第三方庫,module A是tcmalloc,tcmalloc需要攔截所有module的IAT表中原來調用module B中malloc的slot,同時還要攔截所有module B中本地調用malloc的call指令,將他們都攔截到tcmalloc中相應的替換函數。
2)直接修改需要攔截的內存管理函數實現,將函數空間的前幾個bytes修改成一個跳轉指令,跳轉到新函數的地址空間,如下圖B所示:
圖B,tcmalloc保留所有module的IAT內容和本地call指令,只修改module B中malloc的實現,將最前面bytes修改成一個jmp指令,將程序的指令流跳轉到tcmalloc中相應的提替換函數。
Google的tcmalloc組件正是以第二種方式無縫攔截了內存管理函數,修改原有目標函數的前kRequiredTargetPatchBytes(5)字節,將程序強制跳轉到tcmalloc自己的內存管理函數。當然tcmalloc更爲周到地考慮了以下幾點:
-
tcmalloc接管了底層運行庫和第三方庫中的整套內存管理方案,攔截了各模塊中所有的內存管理函數:malloc, free, realloc, calloc, new, newArray, delete, deleteArray, newNothrow, newArrayNothrow, kDeleteNothrow, deleteArrayNothrow, msize, expand, callocCrt
-
準確區分程序運行時各個內存空間的分配者,嚴格遵循由誰分配則由誰負責釋放的原則,程序在tcmalloc攔截前申請分配的內存空間由原始內存釋放函數進行釋放,在tcmalloc攔截後申請分配的內存空間由tcmalloc的內存釋放函數進行釋放,保證整個程序運行正確性和最終dump信息的準確性;
-
保證每個內存管理函數只會被攔截一次,對某些DLL中export forwarding的內存管理函數,tcmalloc會遍歷整個export鏈找到最終的實現函數進行攔截;
-
對於顯示(explict)鏈接的DLL庫,tcmalloc通過攔截loadLibrary, LoadLibraryExW, FreeLibrary等module操作函數來做到攔截這些模塊中的內存管理函數
-
tcmalloc考慮了unpatch的過程,上層程序可以通過適當的操作,恢復到原始運行庫提供的內存管理方案,所以tcmalloc實現中不僅要修改目標函數的內容,還需要將被修改前的內容進行保存,在適當的時候進行還原。
單一函數攔截流程:
下面從tcmalloc如何攔截單個內存管理函數開始介紹,文件preamble_patcher_with_stub.cc中的函數
1 |
|
實現了對單個函數的攔截邏輯,整個流程中涉及了三個至關重要的變量,他們指向的三個地址空間,理解這三個地址空間含義也就理解了tcmalloc的整個攔截流程:
-
target_function:需要被攔截的目標函數地址,譬如運行庫的malloc函數地址;
-
replacement_function:tcmalloc中用來替換被攔截函數的新函數地址,譬如tcmalloc中的Perftools_malloc函數就是攔截運行庫malloc函數後的替換函數;
-
preamble_stub:用來存放目標函數起始幾個bytes內容的空間,這個空間是tcmalloc通過函數AllocPageNear額外申請的,具體有兩個作用將下面介紹;
這個三個變量對應函數的前三個參數,函數的後兩個參數相對比較簡單:
stub_size:表示preamble_stub內存塊的總大小;
bytes_needed:作爲返回值,傳遞給函數的調用者該攔截過程實際佔用preamble_stub的字節數。
攔截流程具體如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
|
下圖是攔截之後三個空間所包含的內容:
圖中黃色部分表示tcmalloc所做的修改,preamble_stub最初的kRequiredStubJumpBytes字節內容是target_function最前面kRequiredStubJumpBytes字節內的指令經過相對地址重計算後的替代指令;kRequiredStubJumpBytes字節後面跟着一條JMP指令用來跳轉到target_function中第(kRequiredStubJumpBytes + 1)byte地址空間;JMP指令後還跟着幾條trampoline指令,用來處理preamble_stub和target_function的地址空間間隔超過4G的情況,這裏不做過多介紹。target_function最前面kRequiredStubJumpBytes字節用一個JMP指令替代,跳轉到tcmalloc的replacement_function的地址空間。從中可以看到preamble_stub的作用其實有兩個:
-
當tcmalloc攔截原始的內存管理函數後,如果需要調用target_function函數,譬如釋放tcmalloc攔截前已經分配的內存空間,則只需要call preamble_stub就可以實現。
-
當需要unpatch內存管理函數時,只需要對preamble_stub前kRequiredStubJumpBytes字節內的指令進行patch的逆操作,並拷貝回target_function的空間就可以了。
相關文件:
tcmalloc中主要有4個文件涉及到函數攔截邏輯,分別如下:
-
patch_functions.cc:無縫攔截所有DLL中的內存管理函數和windows kernel32模塊內針對heap進行操作的函數。
-
preamble_patcher.cc:主要實現指令的反彙編邏輯,判斷各指令類型和計算地址符在指令中的偏移;將RawPatchWithStub進行了包裝,檢查三個地址空間的有效性和準確性;針對module中的export forwarding情況進行處理,根據JMP指令找到真正的target_function實現函數 (ResolveTarget)。
-
preamble_patcher_with_stub.cc:主要實現了對單個函數的攔截功能(前面已經介紹)。
-
libc_override.h:tcmalloc中有關函數攔截的函數定義。
以下將主要介紹patch_functions.cc中如何對module進行攔截的流程。
相關數據結構:
在介紹流程之前,先簡單介紹一下patch_functions.cc中主要涉及的幾個重要數據結構:
LibcInfo:這個類與需要被攔截的module一一對應,該類通過成員函數patch對module中所有內存管理函數進行攔截,需要攔截的函數都定義在enum中:
1 2 3 4 5 6 7 8 9 10 |
|
這個類還有有三個重要的數據成員:
-
function_name_:記錄了所有需要被攔截的函數名,可以通過調用windows函數GetProcAddress得到需要被攔截的函數地址;
-
static_fn_:用於靜態鏈接的庫,動態鏈接時不會用到,在這裏不做介紹;
-
windows_fn_: 需要被攔截的函數地址,即前面提到的target_function,這個成員變量是在函數PopulateWindowsFn內進行賦值的,該函數通過遍歷function_name_找到module中所有需要攔截的函數地址,並通過調用PreamblePatcher::ResolveTarget()函數遍歷module的export forwarding鏈找到真正的target_function實現。
LibcInfoWithPatchFunctions:該類繼承自LibcInfo,通過Template來具體對應一個需要被攔截的module,由於每個module都可能有自己的內存管理函數和需要攔截的替換函數,tcmalloc通過顯示的定義一堆
1 2 3 4 5 |
|
來表示各個加載到內存的module。該類有兩個重要的數據成員:
-
origstub_fn_:保存了攔截後target_function的調用地址,即上面提到的各個被攔截函數相對應的preamble_stub地址。
-
perftools_fn_:保存了tcmalloc的替換函數,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
WindowsInfo:該類與LibcInfo十分相似,但它主要負責攔截windows kernel32中針對heap進行操作的函數,需要攔截的函數都定義在enum中:
1 2 3 4 5 |
|
下來的代碼定義了與其相對應的tcmalloc替換函數:
1 2 3 4 5 6 7 8 9 |
|
tcmalloc攔截這些windows api並不是爲了接管windows自身的heap操作邏輯,而是爲了對內存操作進行計數。每個替換函數裏面都簡單調用了origstub_fn所指向的原有windows api實現,只是在每個api調用前後增加一些計數hook。因爲kernel32只會被加載一次,所以WindowsInfo在tcmalloc中也是以單例形式存在的。
ModuleEntryCopy:該類保存每個module被加載到內存後的加載信息,包括該module的加載地址和module的大小;在LibcInfo被PopulateWindowsFn前,該類還負責保存module中需要被攔截函數的函數地址。
攔截流程:
tcmalloc究竟是在何時對函數進行了攔截?一切還得從文章最開始所提到的兩個配置講起,使用tcmalloc時只需要在程序中添加兩項配置:additional dependencies: libtcmalloc_minimal.dll; force symbol references:__tcmalloc; 其中第一項是配置任何DLL都所必需的步驟,而第二個選項是由於在實際工程裏面不會顯式調用tcmalloc模塊內的函數,而導致編譯器在編譯優化階段忽略整個tcmalloc模塊,所以需要強制引入一個該模塊內的符號,即__tcmalloc,告訴編譯器tcmalloc是工程所依賴的模塊,對於32位的系統只需要強制引入符號_tcmalloc即可。其實__tcmalloc在tcmalloc裏面只是一個空函數,不起任何作用,那麼哪裏纔是tcmalloc攔截的真正入口?那得從另一個類TCMallocGuard說起,在文件Tcmalloc.cc中定義了一個TCMallocGuard的靜態對象module_enter_exit_hook
1 2 3 4 5 6 7 8 |
|
看到函數調用ReplaceSystemAlloc()時,謎底已經揭曉,這正是tcmalloc攔截內存管理函數的入口,所有的無縫操作都是從這裏開始,在程序初始化靜態變量module_enter_exit_hook之後,在正式跳轉到main函數之前。
下面是tcmalloc攔截一個函數時的調用堆棧:
其中函數PatchAllModules會調用windows 函數EnumProcessModules遍歷已經加載到內存的所有module,並且重複調用RawPatchWithStub對每個內存管理函數進行攔截。
最後還需要指出的一點是tcmalloc還攔截了windows的LoadLibrary函數,當每次有新的module顯式加載到程序時,都會調用PatchAllModules函數,對新加入的module內可能存在的內存管理函數進行攔截。