西瓜视频稳定性治理体系建设二Raphae

摘要

Raphael[1]是西瓜视频基础技术团队开发的一款native内存泄漏检测工具,广泛用于字节跳动旗下各大App的native内存泄漏治理,收益显著。工具现已开源,本文将通过原理、方案和实践来剖析Raphael的相关细节。

背景

Android平台上的内存问题一直是性能优化和稳定性治理的焦点和痛点,Java堆内存因为有比较成熟的工具和方法论,加上hprof快照作为补充,定位和治理都很方便。而native内存问题一直缺乏稳定、高效的工具,仅有的mallocdebug[6]不仅性能和稳定性难以满足需要,还存在Android版本兼容的问题。

现状

事实上,native内存泄漏治理一直不乏优秀的工具,已知的可用于调查native内存泄漏问题的工具主要有:LeakTracer、MTrace、MemWatch、Valgrind-memcheck、TCMalloc、LeakSanitizer等。但由于Android平台的特殊性,这些工具要么不兼容,要么接入成本过高,很难在Android平台上落地。这些工具的原理基本都是:先代理内存分配/释放相关的函数(如:malloc/calloc/realloc/memalign/free),再通过unwind回溯调用堆栈,最后借助缓存管理过滤出未释放的内存分配记录。因此,这些工具的主要差异也就体现在代理实现、栈回溯和缓存管理三个方面。根据这些工具代理实现的差异,大致可以分为hook和LD_PRELOAD两大类,典型的如mallocdebug[5]和LeakTracer。

mallocdebug

mallocdebug是Android系统自带的内存调试工具(官方Native内存调试有相关介绍),虽然没有额外的接入代码,但开启方式和核心功能等都受Android版本限制。

我们在线下尝试使用mallocdebug监控西瓜视频App(配置wrap.sh)时发现,正常启动时间小于1s的机型(Pixel2Android10),其冷启动时间被拉长到了11s+。而且在正常使用过程中滑动时的卡顿感非常明显,页面切换时耗时难以接受,监控过程中应用的使用体验极差。不仅如此,西瓜视频在mallocdebug监控过程中还会遇到必现的栈回溯crash(堆栈如下,《libunwindllvm编年史》[8]有相关分析)。

LeakTracer

LeakTracer是另一个比较知名的内存泄漏监控工具,其原理是:通过LD_PRELOAD机制抢先加载一个定义了malloc/calloc/realloc/memalign/free等同名函数的代理库,这样就全局代理了应用层内存的分配和释放,通过unwind回溯调用栈并过滤出疑似的内存泄漏信息。Android平台上的LD_PRELOAD是被严格限制的,因为其没有独立的unwind实现,依赖系统的unwind能力,也会遇到mallocdebug遇到的栈帧兼容问题;如果把LeakTracer集成到目标so里通过override方式实现代理,只能拦截到本so里显式的内存分配/释放,无法拦截到其他so和跨so调用的内存分配/释放。通过native插桩的方式也是如此,只能监控局部单纯的内存泄漏,无法全局监控内存使用。

综合以上分析和接入体验,我们不难发现,这些内存泄漏监控工具在Android平台上实际接入时基本都存在以下三个比较典型的问题:

流程繁琐:需要配置wrap.sh/rootpermission/setprop等,受Android版本限制兼容问题:unwind库存在严重的兼容性问题,libunwind_llvm无法正确回溯GNU编译的栈帧性能问题:官方的mallocdebug性能数据是损失10倍以上,实测西瓜开启后在中高端机上不可用我们的需求

西瓜视频App是一个汇集了视频播放、特效拍摄、视频剪辑辑、P2P加速等native代码非常多的中大型应用,每个native代码相关的模块背后都有一个专业团队在高速迭代,加上日人均使用时长超过分钟的影响,西瓜视频App的native内存问题治理难度非常大。事实上,单纯的内存泄漏问题相对较少,更多的是因为业务逻辑不合理带来的内存使用问题,需要工具渗透到App运行的过程中进行监控,无形中提高了对工具性能和稳定性的要求。

线上native内存问题基本都是以虚拟内存触顶的形式暴露出来的。在西瓜视频App里,虚拟内存的消耗除了上述几大模块外,还有其他几个消耗大户,如线程、webview、Flutter、硬件加速、显存等。事实上,malloc/calloc/realloc/memalign等相对于mmap/mmap64直接分配出的内存在整个虚拟内存空间中通常占比比较小。因为内存问题通常以虚拟内存耗尽的形式表现出来,只有尽可能多的收集各种内存消耗来无限逼近虚拟内存上限,才能准确找出虚拟内存耗尽的原因。因此,像mallocdebug这样只监控malloc/calloc/realloc/memalign/free等根本无法满足内存治理需要,覆盖mmap/mmap64/munmap等尽可能多的内存分配形式是监控工具必须要做的。

综合上面的分析可以得出,西瓜视频App乃至整个字节跳动旗下其他App,对于一个通用的native内存泄漏监控工具的诉求主要有以下几个方面:

接入层面:不依赖Android版本,无需root,对业务渗透尽可能低稳定性:不存在影响业务的稳定性问题,可以满足线上使用的诉求性能层面:没有明显的性能问题,达到可线上使用的标准监控范围:不局限于malloc/calloc/realloc/memalign/free,至少还能覆盖mmap/mmap64/munmapRaphael核心设计

通过前面的分析可以知道,一个完整的native内存泄漏监控工具主要包含三部分:代理实现、栈回溯和缓存管理。代理实现是解决Android平台上接入问题的关键,栈回溯是性能和稳定性的核心,缓存逻辑在一定程度上也会直接影响性能和稳定性。接下来我们会从四个方面介绍Raphael的核心设计。

代理实现

鉴于wrap.sh和LD_PRELOAD在Android平台上不具有通用性,首先被排除。又因mallochook只能代理malloc/calloc/realloc/free,无法覆盖mmap/mmap64/munmap,也被放弃。但受mallochook实现方式的启发,借助于inlinehook/PLThook工具我们可以实现同样的代理效果,这其中比较有代表性的工具主要有Android-Inline-Hook[3]和xHook[1]。

xHook是比较优秀的PLThook工具代表,其稳定性可以达到上线标准。因其实现依赖正则,同时hook的so或函数比较多时,hook耗时会比较明显。此外,其原生实现只能hook当前已经加载的so,对于未加载的没做特殊处理,如果用来做长时间的进程级监控,需要解决增量sohook问题。不过这种hook方式非常适合做so定向监控。

与PLThook原理不同,inlinehook则是在目标函数的头部直接插入跳转指令,其hook的是最终的函数实现,不存在增量sohook问题,hook效率高效直接。但inlinehook在hook那些可能正在执行的函数后,需要挂起相关线程进行指令修正,这个是inlinehook的痛点,现有hook实现很多没有做指令修复,或者在指令修复时或多或少都存在一些问题。

Raphael在早期的验证版本里采用xHook来实现代理接入。后续为了实现长时间进程级监控,以覆盖更多的业务场景,Raphael又通过Android-Inline-Hook解决增量sohook问题,通过xHook实现定向监控。为了进一步提升工具的性能和稳定性,Raphael内部最新版本已切换到了bytehook(字节跳动自研的PLThook工具,可自动处理增量sohook问题)。

栈回溯

定位一个对象或者一段内存通常可以通过引用/依赖关系,也可以通过创建/分配时的堆栈。Java堆内存因为有明确的组织形式和清晰的依赖关系,可以通过依赖关系静态分析内存泄漏问题。但native堆内存依赖/引用比较隐晦,也没有Java堆内存那样明确的组织格式,无法通过依赖/引用关系进行静态分析,只能通过分配时的堆栈来辅助定位。栈回溯(unwind)是native层获取调用堆栈的通用方式,是native内存泄漏监控工具不可或缺的核心,同时也是工具性能和稳定性的瓶颈所在。接下来本文将从栈回溯工具选取、限制栈回溯频次、减少无用栈回溯三个方面介绍Raphael在栈回溯上所做的工作。

栈回溯工具选取

Android平台上常用的32位栈回溯库主要有:libunwind_llvm、libunwind(nongnu)、libgcc_s、libudf、libbacktrace、libunwindstack等,实践证实这些工具或多或少都存在一些问题,以下是我们基于三个主流的栈回溯库做的简单对比分析(平台:Pixel2Android10,性能:Demo里统计16层栈帧回溯的总耗时;兼容性:字节跳动旗下多个应用长时间的优化治理实践)

栈回溯涉及到的东西比较多,想要自己短时间内实现一个在稳定性、回溯性能、回溯成功率等方面都表现优异的32位栈回溯工具难度非常大。为了快速验证并解决实际机问题,Raphael在早期版本里采用的是libunwind_llvm,随后切换到libunwind_llvmlibunwind(nongnu),通过libunwind_llvm保证回溯性能,在回溯深度低于2层时切换到libunwind(nongnu),以保证回溯成功率。最新版本里则采用的是libudf,兼具了性能和回溯成功率。相对而言,64位下基于FP的栈回溯实现性能和稳定性基本都能满足需求,这里不做过多介绍。Rapahel同时也在设计时做了充分的扩展考虑,可以轻松切换到其他更优秀的栈回溯实现。

限制栈回溯频次

即便是libudf实现,其在demo里回溯16层栈帧的平均耗时也需要0.6ms,监控工具实际运行时对App性能的影响是很明显的。提升监控性能的途径除了直接优化栈回溯性能外,减少回溯频次也是十分有效的手段。我们在西瓜视频App的优化治理实践中发现,多数场景小于byte的内存分配其频率约占70%以上,但线上遇到的native内存触顶问题,却很少是由小内存泄漏引发的,监控小内存泄漏对于解决线上native内存触顶问题没有实质效果。即便真的是由小内存引发的,这个需要高频和必现的场景才能达到,这类问题通常在线下单测(定向监控)场景是完全可以覆盖到的。基于此,Raphael通过设定内存阈值来控制栈回溯频次,可以大幅降低栈回溯的性能损耗。

减少无用栈回溯

受限于代理流程和栈回溯实现机制,从代理函数入口到回溯开始的路径上会存在几层跟分配堆栈无关的函数调用,这几层调用最终会体现在最后回溯成功的堆栈上(下图的红色部分),每次内存分配都回溯这几层无用的调用链是十分损耗性能的。解决这种问题的直观方法就是减少甚至完全规避这种无关的栈回溯,体现在代码层面就是减少代理入口到回溯开启函数之间的调用层级。inline是一种简单直接的实现方式,也可以直接在代理入口处提前构建回溯的context数据。

缓存管理

缓存管理作为native内存监控的重要一环,对整个监控工具性能的影响至关重要。以mallocdebug和LeakTracer为例,它们都是通过分配后的内存地址作为key来计算hash后散列存储的,并通过一个全局锁来同步缓存更新的时序。两者不同的是,mallocdebug会通过堆栈聚合调用链完全相同的内存分配记录,其缓存的存储单元通过malloc动态分配;而LeakTracer则不会根据堆栈聚合,其存储单元会预先分配一部分,缓存不足时也会动态申请。通过以上分析和实测可以发现,mallocdebug的实际性能比LeakTracer低很多,原因主要体现在堆栈聚合和缓存动态分配上。

对比mallocdebug和LeakTracer的源码也可以发现:运行时的堆栈聚合是完全没有必要的;如果限制内存监控的阈值,缓存空间和缓存单元的上限都可以控制在一定范围内的,不需要动态申请,可以减少动态分配的性能损耗;此外,由于native内存分配和释放频率比较高,全局锁一定程序上会影响整体性能,通过key计算hash后再散列存储时不需要全局锁。

Raphael是预先分配固定大小的缓存空间,除了发生内存触顶导致的crash外,缓存单元提前耗完也认为存在内存泄漏问题。这主要是因为:对于32位进程,其虚拟内存的上限通常是4G,正常运行时相对比较容易触达上限,而64位进程的虚拟地址空间非常大,实际很难遇到虚拟内存触顶的case,但遇到物理内存不足的概率则要大很多,这与32位进程基本相反。通过控制vmPeak阈值和缓存单元余量可以有效捕捉到内存泄漏数据,最终实现稳定可靠的全自动内存泄漏监控及消费流程

监控范围

通过前面的分析可以知道,只监控malloc/calloc/realloc/memalign/free是无法满足治理需求的,这主要是因为malloc/calloc/realloc/memalign/free等分配出的内存通常在整个虚拟内存空间里占比较小,常见的内存消耗大户Thread、webview、Flutter、硬件加速、显存等,都不是通过这些函数分配出的。为了能够对Android平台上的native内存触顶问题精准归因,监控需要无限逼近虚拟内存的上限,这就需要监控尽可能多的内存分配形式。

Android上的内存操作主要是malloc/calloc/realloc/memalign/free和mmap/mmap64/munmap,同监控malloc/calloc/realloc/memalign/free相比,监控mmap/mmap64/munmap有两点不同:一个是线程栈的释放问题,虽然创建线程时是通过mmap/mmap64分配的栈内存,但栈内存的释放并不一定是通过显式调用munmap实现的;另一个是监控重入问题,当通过malloc/calloc/realloc/memalign等分配大内存时,底层通常是通过mmap/mmap64实现的,两类接口同时监控时会存在重入问题。

栈内存释放

线程的栈内存又分为信号栈和执行栈,信号栈在调用voidpthread_exit(void*return_value)接口时会通过munmap即刻释放,而执行栈的释放则有两种形式:

voidpthread_exit(voidreturn_value)函数体里,当线程状态为THREAD_DETACHED时会直接通过void_exit_with_stack_teardown(voidstack,size_tsz)释放intpthread_join(pthread_tt,void**return_value)里通过pthread_internal_remove_and_free,最终在pthread_internal_free里通过munmap释放

综上,最终通过munmap释放的内存都可以被监控到,而通过_exit_with_stack_teardown释放的内存则无法拦截到。我们针对这种情况做了特殊处理:在Raphael里代理拦截了voidpthread_exit(void*),并判断此时线程状态是否为THREAD_DETACHED,如果是则在监控里直接移除相关记录,否则不移除。

重入问题

下图是一个典型的重入现场,其上层的malloc函数最终调用到了mmap函数,同时监控两类内存接口时就会遇到此类问题。重入问题带来的一个挑战是缓存如何管理,同一个缓存里只能维护一个记录,维护两个记录的逻辑和性能过于复杂。此外,从malloc到mmap的堆栈是固定的,这几层堆栈对分析内存泄漏完全没用,因为这个时候



转载请注明地址:http://www.jiankongxingye.com/jkfz/26845023.html
  • 上一篇文章:
  • 下一篇文章: 没有了