Windows放大镜采集内存泄漏问题定位与处理
背景
Windows下有多种用于采集画面的接口,如GDI,DXGI,以及最新的WGC,但这几种接口都缺乏内容过滤的能力,而具备该能力的采集接口目前仅有古老的放大镜接口(Magnification)。放大镜接口性能极差,BUG众多,目前属于基本废弃的状态。但由于内部业务需求的存在,改采集方式仍然是目前主要使用的采集方式之一。
在放大镜接口众多的bug中,存在一个对稳定性影响非常严重的bug,那就是内存泄漏。放大镜接口提供了一对初始化/反初始化接口MagInitialize
和MagUninitialize
,但在实际使用中发现,即使在确保二者正确调用的情况下,每次投屏都会出现内存泄漏,泄露量和分辨率相关,但由于官方文档没有任何资料提及这部分内存要如何释放,网上的资料也没有任何相关说明。在和其他同行的交流中得知,一个可行的规避方案是通过将放大镜接口封装成单例类使用,避免多次调用初始化接口。
但这样做的问题是会导致一旦初始化后,放大镜采集接口就会一直占用数百兆内存得不到释放,该问题对32位应用影响较大。由于Windows下32位应用在不开启3G虚拟内存内存的情况下,可用虚拟内存仅2G,除去可执行文件映射占用,实际情况下普遍可用仅1G作用。而对应音视频软件而言,其本身对内存占用是偏高的,这就导致软件在运行时经常出现虚拟内存不出而内存分配失败,进而出现崩溃。根据内部业务崩溃上报数据来看,超过30%的崩溃是由于虚拟内存不足导致的。所以该方案依然不是最佳选择。
分析
收集信息
为了彻底解决该问题,首先需要定位内存泄漏的来源。这里我们使用Visual Studio自带的内存分析工具,先初步定位泄漏点。在桌面分辨率为2560*1440的环境下,运行测试程序,先进行一次投屏后停止以避免部分仅需单次初始化的内存资源(如动态库的加载)对后续分析的干扰。分别取第二次投屏和第三次投屏停止后的内存快照
可以看到,第三次投屏停止后静止内存出现了明显的上升,增长了大约14M。进一步查看具体的内存分配情况,发现绝大部分泄露都来源于一个内存块。
如图所示,<0x18B9F040>
内存块泄露了14745564KB,约等于14M的内存。继续查看该内存块的分配堆栈
大胆猜测
可以看到其确实是放大镜采集接口中分配的,而且从堆栈中看,应该是D3D9的纹理数据。那如果我们能在代码中获取到这块内存的地址,再手动释放是不是就可以解决这个问题呢?但如何在运行时通过代码获取到这块内存遇到了问题。由于放大镜采集库是闭源的,我们无从得知这个内存地址是保存在哪里。在经过一番思索后,我发现泄漏的内存大小和一张屏幕画面的ARGB图像大小(256014404=14745600)非常接近,仅相差32个字节。那可以大胆猜测这块内存是用来保存采集画面的,而在放大镜采集接口中,我们会通过注册的一个回调函数,拿到采集画面数据的内存指针。那这个内存指针是不是就是我们要找的泄露内存块的指针呢?在代码中将回调函数中对应的参数srcdata
打印出来后发现,二者仅相差0x20
字节。经过多次实验,发现该现象是稳定复现的。因此我们可以作出一个初步结论,回调函数中srcdata
的值+0x20
的偏移,就是VS内存分析工具中检测到的内存泄露地址。
在先忽略为什么会存在0x20
的偏移,并假设这块内存就是泄露内存的前提下,我尝试在反初始化时调用free
将这块内存释放,但测试时发现这样会导致崩溃,说明这块内存无法通过free
进行释放。再次回去查看分配堆栈时,发现其是通过RtlHeapAllocate
方法进行分配的,而在Windows的内存管理机制中,会先根据用途的不同创建不同的堆,然后再从这些堆上分配内存,最常见的是进程默认堆和crt堆。C/C++中的内存分配函数,如malloc
/new
就是从crt堆上进行分配,而Win32接口大部分则是从进程默认堆进行分配。所以如果srcdata
指向的内存块不是在crt堆上分配的,就会导致崩溃。
深入调试
所以为了释放这块内存,我们还需要先确认这块内存所属的堆。这里我们可以通过Windbg来查看。这里遇到的一个小问题是由于当前进程已经在VS的调试中,windbg是无法附加上去的,这里需要使用非侵入式附加。
附加上去后,首先查看进程的堆情况
可以看到,当前进程目前存在三个堆。接下来,我们查看一下srcdata地址的情况。
可以看到,srcdata
的值就是内存块的起始地址(不考虑堆块的8字节块头数据),大小为0xe11000=14749696字节,和图像大小14745600字节也非常接近,多出的96字节是因为内存分配需要对齐到4k而额外分配的。这里如果我们将VS内存分析工具检测到的泄露地址作为参数查看,会发现是同一块,说明VS内存分析工具检测的地址存在一些偏差。
srcdata
所属的堆是0x00950000,也就是堆信息中的第一个,通常来说第一个堆就是进程默认堆,我们可以通过查看进程环境块(!peb
)来确定。
于是我们就可以确定泄露的内存块属于系统默认堆,直接通过HeapAllocate
相关方法进行分配,因此我们需要通过HeapFree
方法进行释放,并指定堆为系统默认堆。系统默认堆可以通过GetProcessHeap
接口获取。
修复验证
基于上述的分析,我们可以在回调时将srcdata
保存下来,并在析构时将其释放。在添加对应修复代码后再次测试,结果如下
可以看到效果非常明显,虽然依然存在极少量泄露(分析后发现主要是放大镜接口中分配的一些内部对象等,难以进一步处理),但整体可控,反复投屏上百次泄漏量才数兆,问题基本解决。
总结
该问题是一个困扰已久的问题,但受限于缺少文档和实现盲盒,迟迟没有找到解决方案,该问题的解决预期能够比较有效的提升软件的稳定性。不过由于放大镜接口本身的性能问题,在开发中依然尽量避免使用,进行相关需求可行性分析时应当慎重考虑。