又到了一个总结提炼的阶段,此次想具体聊聊游戏引擎中使用的内存管理模块tcmalloc组件的使用心得。项目的前期曾经遇到过内存瓶颈,特别是windows系统下的客户端程序在经历长时间运行以后会出现内存占用率很高疑似泄漏的现象,排查了好久都没有找到缘由,甚至一度没法定位问题出自游戏脚本层仍是引擎层,后来在引擎中连接了tcmalloc组件,经过实时dump程序的内存信息最终找出了泄漏的元凶。tcmalloc的另外一个优点就是经过高效率内存分配来提升游戏运行时性能,不得不说在使用tcmalloc以后,整个游戏的稳定性和效率都有了很大的提高。为了从此更有效和稳定地使用tcmalloc组件,就在这里深刻剖析一下这个神器。Tcmalloc是Google Perftools中的一个组件,提供一整套高效健壮的内存管理方案,比传统glibc的内存分配和释放要快数倍;其次,基于tcmalloc之上的heapprofiler能够实时dump程序中heap的使用信息,是一个很好的检测内存泄漏的辅助工具;同时tcmalloc的使用又是极其方便,只须要在编译时增长一个连接选项,就能够无缝拦截(patch)原生操做系统运行库中的内存分配和释放接口,而无需修改已经完成的项目工程代码,大大减小移植整合的成本。windows
在windows平台下,tcmalloc能够经过静态库或者动态库(DLL)的形式嵌入到工程里面,这里将主要 分析tcmalloc如何DLL动态连接到工程里面,同时将重点剖析一下tcmalloc如何在不改变工程原有代码的前提下无缝地拦截windows原生内存管理接口。api
以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将在程序静态变量初始化阶段拦截全部原生内存管理接口。sass
要理解tcmalloc如何无缝拦截底层运行库(runtime library)中的内存管理函数,首先须要理解windows平台下的可执行文件和 exe加载流程。windows平台下的可执行文件是以PE(Portable Executable)格式存在的,由各个不一样的段组织而成,如.data .text .rsrc等,其中.text段包含了模块内全部代码的二进制输出,相应的函数调用是以数据结构
call XXXXXXXXapp
的汇编指令存在,其中XXXXXXXX表示程序运行时的函数虚拟地址。因为本模块PE文件各个段的布局和相应加载地址是在连接时决定,因此对于本模块内的函数调用能够在连接时就计算获得相应的函数地址,以下图所示,对某.text段中的.func函数进行调用,.func函数地址XXXXXXX能够经过将该段的加载地址和函数在段内的偏移二者相加获得:ide
对于隐式(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中的函数
SideStepError PreamblePatcher::RawPatchWithStub(void* target_function, void* replacement_function, unsigned char* preamble_stub, unsigned long stub_size, unsigned long* bytes_needed)
实现了对单个函数的拦截逻辑,整个流程中涉及了三个相当重要的变量,他们指向的三个地址空间,理解这三个地址空间含义也就理解了tcmalloc的整个拦截流程:
target_function:须要被拦截的目标函数地址,譬如运行库的malloc函数地址;
replacement_function:tcmalloc中用来替换被拦截函数的新函数地址,譬如tcmalloc中的Perftools_malloc函数就是拦截运行库malloc函数后的替换函数;
preamble_stub:用来存放目标函数起始几个bytes内容的空间,这个空间是tcmalloc经过函数AllocPageNear额外申请的,具体有两个做用将下面介绍;
这个三个变量对应函数的前三个参数,函数的后两个参数相对比较简单:
stub_size:表示preamble_stub内存块的总大小;
bytes_needed:做为返回值,传递给函数的调用者该拦截过程实际占用preamble_stub的字节数。
拦截流程具体以下:
while (读取目标函数的内容偏移量(preamble_bytes)小于kRequiredTargetPatchBytes) { // 反汇编获得相应的指令类型 InstructionType instruction_type = disassembler.Disassemble(target + preamble_bytes, cur_bytes); if (IT_JUMP == instruction_type) { // 若是是跳转指令 1) 将指令类型字节码拷贝到preamble_stub 2) 从新计算该指令相对跳转偏移original_jump_dest - stub_jump_from,并拷贝到preamble_stub,保证该迁移后的指令在执行时可以正确跳转到原来指令应该跳转到的目的地址,若是原目的地址在须要迁移kRequiredTargetPatchBytes的字节内,则还须要再一次从新计算相对跳转偏移到新的目的地址。 } else if (IT_GENERIC == instruction_type) { if (IsMovWithDisplacement(target + preamble_bytes, cur_bytes)) { // 若是是mov displace指令 1) 将指令类型字节码拷贝到preamble_stub 2) 从新计算mov的目的地址,逻辑与上面处理跳转指令相似 } else { // 其余普通指令 1)将整个指令简单copy到preamble_stub } } // 将读取目标函数内容的指针向后偏移刚刚copy的指令字节数 preamble_bytes += cur_bytes; } if (NULL != bytes_needed) // 计算preamble_stub会被占用的字节数 *bytes_needed = stub_bytes + kRequiredStubJumpBytes + required_trampoline_bytes; // Now, make a jmp instruction to the rest of the target function (minus the // preamble bytes we moved into the stub) and copy it into our preamble-stub. // find address to jump to, relative to next address after jmp instruction (注释很清晰,很少解释) int relative_offset_to_target_rest = ((reinterpret_cast<unsigned char*>(target) + preamble_bytes) - (preamble_stub + stub_bytes + kRequiredStubJumpBytes)); // jmp (Jump near, relative, displacement relative to next instruction) //在preamble_stub的最后添加一条特殊jmp指令ASM_JMP32REL,其目的是保证上面所提到的2,5需求能正确实现,当程序须要调用原来target_function时,在执行preamble_stub最前几个bytes指令后可以成功跳转到原来target_function空间在kRequiredStubJumpBytes后的指令序列继续执行 preamble_stub[stub_bytes] = ASM_JMP32REL; // copy the address memcpy(reinterpret_cast<void*>(preamble_stub + stub_bytes + 1), reinterpret_cast<void*>(&relative_offset_to_target_rest), 4); // Inv: preamble_stub points to assembly code that will execute the // original function by first executing the first cbPreamble bytes of the // preamble, then jumping to the rest of the function. // Overwrite the first 5 bytes of the target function with a jump to our // replacement function. // (Jump near, relative, displacement relative to next instruction) // 全部准备工做结束,万事俱备开始真正拦截,往目标函数的前kRequiredStubJumpBytes写入一个跳转指令,跳转到tcmalloc的替换函数 target[0] = ASM_JMP32REL; // Find offset from instruction after jmp, to the replacement function. // 计算tcmalloc替换函数的相对地址 int offset_to_replacement_function = reinterpret_cast<unsigned char*>(replacement_function) - reinterpret_cast<unsigned char*>(target) - 5; // complete the jmp instruction memcpy(reinterpret_cast<void*>(target + 1), reinterpret_cast<void*>(&offset_to_replacement_function), 4); // 圆满完成
下图是拦截以后三个空间所包含的内容:
图中黄色部分表示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中:
enum { kMalloc, kFree, kRealloc, kCalloc, kNew, kNewArray, kDelete, kDeleteArray, kNewNothrow, kNewArrayNothrow, kDeleteNothrow, kDeleteArrayNothrow, // These are windows-only functions from malloc.h k_Msize, k_Expand, // A MS CRT "internal" function, implemented using _calloc_impl k_CallocCrt, kNumFunctions };
这个类还有有三个重要的数据成员:
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经过显示的定义一堆
static LibcInfoWithPatchFunctions<0> main_executable; static LibcInfoWithPatchFunctions<1> libc1; static LibcInfoWithPatchFunctions<2> libc2; static LibcInfoWithPatchFunctions<3> libc3; ...
来表示各个加载到内存的module。该类有两个重要的数据成员:
origstub_fn_:保存了拦截后target_function的调用地址,即上面提到的各个被拦截函数相对应的preamble_stub地址。
perftools_fn_:保存了tcmalloc的替换函数,以下所示:
static void* Perftools_malloc(size_t size) __THROW; static void Perftools_free(void* ptr) __THROW; static void* Perftools_realloc(void* ptr, size_t size) __THROW; static void* Perftools_calloc(size_t nmemb, size_t size) __THROW; static void* Perftools_new(size_t size); static void* Perftools_newarray(size_t size); static void Perftools_delete(void *ptr); static void Perftools_deletearray(void *ptr); static void* Perftools_new_nothrow(size_t size, const std::nothrow_t&) __THROW; static void* Perftools_newarray_nothrow(size_t size, const std::nothrow_t&) __THROW; static void Perftools_delete_nothrow(void *ptr, const std::nothrow_t&) __THROW; static void Perftools_deletearray_nothrow(void *ptr, const std::nothrow_t&) __THROW; static size_t Perftools__msize(void *ptr) __THROW; static void* Perftools__expand(void *ptr, size_t size) __THROW;
WindowsInfo:该类与LibcInfo十分类似,但它主要负责拦截windows kernel32中针对heap进行操做的函数,须要拦截的函数都定义在enum中:
enum { kHeapAlloc, kHeapFree, kVirtualAllocEx, kVirtualFreeEx, kMapViewOfFileEx, kUnmapViewOfFile, kLoadLibraryExW, kFreeLibrary, kNumFunctions };
下来的代码定义了与其相对应的tcmalloc替换函数:
WindowsInfo::FunctionInfo WindowsInfo::function_info_[] = { { "HeapAlloc", NULL, NULL, (GenericFnPtr)&Perftools_HeapAlloc }, { "HeapFree", NULL, NULL, (GenericFnPtr)&Perftools_HeapFree }, { "VirtualAllocEx", NULL, NULL, (GenericFnPtr)&Perftools_VirtualAllocEx }, { "VirtualFreeEx", NULL, NULL, (GenericFnPtr)&Perftools_VirtualFreeEx }, { "MapViewOfFileEx", NULL, NULL, (GenericFnPtr)&Perftools_MapViewOfFileEx }, { "UnmapViewOfFile", NULL, NULL, (GenericFnPtr)&Perftools_UnmapViewOfFile }, { "LoadLibraryExW", NULL, NULL, (GenericFnPtr)&Perftools_LoadLibraryExW }, { "FreeLibrary", NULL, NULL, (GenericFnPtr)&Perftools_FreeLibrary },
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
TCMallocGuard::TCMallocGuard() { if (tcmallocguard_refcount++ == 0) { .... ReplaceSystemAlloc(); // defined in libc_override_*.h tc_free(tc_malloc(1)); ...... } }
看到函数调用ReplaceSystemAlloc()时,谜底已经揭晓,这正是tcmalloc拦截内存管理函数的入口,全部的无缝操做都是从这里开始,在程序初始化静态变量module_enter_exit_hook以后,在正式跳转到main函数以前。
下面是tcmalloc拦截一个函数时的调用堆栈:
其中函数PatchAllModules会调用windows 函数EnumProcessModules遍历已经加载到内存的全部module,而且重复调用RawPatchWithStub对每一个内存管理函数进行拦截。
最后还须要指出的一点是tcmalloc还拦截了windows的LoadLibrary函数,当每次有新的module显式加载到程序时,都会调用PatchAllModules函数,对新加入的module内可能存在的内存管理函数进行拦截。