找回密码
 会员注册
查看: 20|回复: 0

西瓜视频稳定性治理体系建设二:Raphael原理及实践_UTF_8

[复制链接]

4

主题

0

回帖

13

积分

新手上路

积分
13
发表于 2024-10-2 15:44:03 | 显示全部楼层 |阅读模式
摘要Raphael [1]是西瓜视频基础技术团队开发的一款 native 内存泄漏检测工具,广泛用于字节跳动旗下各大 App 的 native 内存泄漏治理,收益显著。工具现已开源,本文将通过原理、方案和实践来剖析 Raphael 的相关细节。背景Android 平台上的内存问题一直是性能优化和稳定性治理的焦点和痛点,Java 堆内存因为有比较成熟的工具和方法论,加上 hprof 快照作为补充,定位和治理都很方便。而 native 内存问题一直缺乏稳定、高效的工具,仅有的 malloc debug [6]不仅性能和稳定性难以满足需要,还存在 Android 版本兼容的问题。现状事实上,native 内存泄漏治理一直不乏优秀的工具,已知的可用于调查 native 内存泄漏问题的工具主要有:LeakTracer、MTrace、MemWatch、Valgrind-memcheck、TCMalloc、LeakSanitizer 等。但由于 Android 平台的特殊性,这些工具要么不兼容,要么接入成本过高,很难在 Android 平台上落地。这些工具的原理基本都是:先代理内存分配/释放相关的函数(如:malloc/calloc/realloc/memalign/free),再通过 unwind 回溯调用堆栈,最后借助缓存管理过滤出未释放的内存分配记录。因此,这些工具的主要差异也就体现在代理实现、栈回溯和缓存管理三个方面。根据这些工具代理实现的差异,大致可以分为 hook 和 LD_PRELOAD 两大类,典型的如 malloc debug [5] 和 LeakTracer。malloc debugmalloc debug是 Android 系统自带的内存调试工具(官方Native 内存调试有相关介绍),虽然没有额外的接入代码,但开启方式和核心功能等都受 Android 版本限制。我们在线下尝试使用 malloc debug 监控西瓜视频 App(配置 wrap.sh)时发现,正常启动时间小于 1s 的机型(Pixel 2 & Android 10),其冷启动时间被拉长到了 11s+。而且在正常使用过程中滑动时的卡顿感非常明显,页面切换时耗时难以接受,监控过程中应用的使用体验极差。不仅如此,西瓜视频在 malloc debug 监控过程中还会遇到必现的栈回溯 crash(堆栈如下,《libunwind llvm 编年史》[8] 有相关分析)。LeakTracerLeakTracer 是另一个比较知名的内存泄漏监控工具,其原理是:通过 LD_PRELOAD 机制抢先加载一个定义了 malloc/calloc/realloc/memalign/free 等同名函数的代理库,这样就全局代理了应用层内存的分配和释放,通过 unwind 回溯调用栈并过滤出疑似的内存泄漏信息。Android 平台上的 LD_PRELOAD 是被严格限制的,因为其没有独立的 unwind 实现,依赖系统的 unwind 能力,也会遇到 malloc debug 遇到的栈帧兼容问题;如果把 LeakTracer 集成到目标 so 里通过 override 方式实现代理,只能拦截到本 so 里显式的内存分配/释放,无法拦截到其他 so 和跨 so 调用的内存分配/释放。通过 native 插桩的方式也是如此,只能监控局部单纯的内存泄漏,无法全局监控内存使用。综合以上分析和接入体验,我们不难发现,这些内存泄漏监控工具在 Android 平台上实际接入时基本都存在以下三个比较典型的问题:流程繁琐:需要配置 wrap.sh/root permission/setprop 等,受 Android 版本限制兼容问题:unwind 库存在严重的兼容性问题,libunwind_llvm 无法正确回溯 GNU 编译的栈帧性能问题:官方的 malloc debug 性能数据是损失 10 倍以上,实测西瓜开启后在中高端机上不可用我们的需求西瓜视频 App 是一个汇集了视频播放、特效拍摄、视频剪辑辑、P2P 加速等 native 代码非常多的中大型应用,每个 native 代码相关的模块背后都有一个专业团队在高速迭代,加上日人均使用时长超过 100 分钟的影响,西瓜视频 App 的 native 内存问题治理难度非常大。事实上,单纯的内存泄漏问题相对较少,更多的是因为业务逻辑不合理带来的内存使用问题,需要工具渗透到 App 运行的过程中进行监控,无形中提高了对工具性能和稳定性的要求。线上 native 内存问题基本都是以虚拟内存触顶的形式暴露出来的。在西瓜视频 App 里,虚拟内存的消耗除了上述几大模块外,还有其他几个消耗大户,如线程、webview、Flutter、硬件加速、显存等。事实上,malloc/calloc/realloc/memalign 等相对于 mmap/mmap64 直接分配出的内存在整个虚拟内存空间中通常占比比较小。因为内存问题通常以虚拟内存耗尽的形式表现出来,只有尽可能多的收集各种内存消耗来无限逼近虚拟内存上限,才能准确找出虚拟内存耗尽的原因。因此,像 malloc debug 这样只监控 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 平台上不具有通用性,首先被排除。又因 malloc hook 只能代理 malloc/calloc/realloc/free,无法覆盖 mmap/mmap64/munmap,也被放弃。但受 malloc hook 实现方式的启发,借助于 inline hook / PLT hook 工具我们可以实现同样的代理效果,这其中比较有代表性的工具主要有 Android-Inline-Hook[3] 和 xHook[1]。xHook 是比较优秀的 PLT hook 工具代表,其稳定性可以达到上线标准。因其实现依赖正则,同时 hook 的 so 或函数比较多时,hook 耗时会比较明显。此外,其原生实现只能 hook 当前已经加载的 so,对于未加载的没做特殊处理,如果用来做长时间的进程级监控,需要解决增量 so hook 问题。不过这种 hook 方式非常适合做 so 定向监控。与 PLT hook 原理不同,inline hook 则是在目标函数的头部直接插入跳转指令,其 hook 的是最终的函数实现,不存在增量 so hook 问题,hook 效率高效直接。但 inline hook 在 hook 那些可能正在执行的函数后,需要挂起相关线程进行指令修正,这个是 inline hook 的痛点,现有 hook 实现很多没有做指令修复,或者在指令修复时或多或少都存在一些问题。Raphael 在早期的验证版本里采用 xHook 来实现代理接入。后续为了实现长时间进程级监控,以覆盖更多的业务场景,Raphael 又通过 Android-Inline-Hook 解决增量 so hook 问题,通过 xHook 实现定向监控。为了进一步提升工具的性能和稳定性, Raphael 内部最新版本已切换到了 bytehook(字节跳动自研的 PLT hook 工具,可自动处理增量 so hook 问题)。栈回溯定位一个对象或者一段内存通常可以通过引用/依赖关系,也可以通过创建/分配时的堆栈。Java 堆内存因为有明确的组织形式和清晰的依赖关系,可以通过依赖关系静态分析内存泄漏问题。但 native 堆内存依赖/引用比较隐晦,也没有 Java 堆内存那样明确的组织格式,无法通过依赖/引用关系进行静态分析,只能通过分配时的堆栈来辅助定位。栈回溯(unwind)是 native 层获取调用堆栈的通用方式,是 native 内存泄漏监控工具不可或缺的核心,同时也是工具性能和稳定性的瓶颈所在。接下来本文将从栈回溯工具选取、限制栈回溯频次、减少无用栈回溯三个方面介绍 Raphael 在栈回溯上所做的工作。栈回溯工具选取Android 平台上常用的 32 位栈回溯库主要有:libunwind_llvm、libunwind (nongnu)、libgcc_s、libudf、libbacktrace、libunwindstack 等,实践证实这些工具或多或少都存在一些问题,以下是我们基于三个主流的栈回溯库做的简单对比分析(平台:Pixel 2 & Android 10,性能:Demo 里统计 16 层栈帧回溯的总耗时;兼容性:字节跳动旗下多个应用长时间的优化治理实践)栈回溯涉及到的东西比较多,想要自己短时间内实现一个在稳定性、回溯性能、回溯成功率等方面都表现优异的 32 位栈回溯工具难度非常大。为了快速验证并解决实际机问题,Raphael 在早期版本里采用的是 libunwind_llvm,随后切换到 libunwind_llvm & libunwind (nongnu),通过 libunwind_llvm 保证回溯性能,在回溯深度低于 2 层时切换到 libunwind (nongnu),以保证回溯成功率。最新版本里则采用的是 libudf,兼具了性能和回溯成功率。相对而言,64 位下基于 FP 的栈回溯实现性能和稳定性基本都能满足需求,这里不做过多介绍。Rapahel 同时也在设计时做了充分的扩展考虑,可以轻松切换到其他更优秀的栈回溯实现。限制栈回溯频次即便是 libudf 实现,其在 demo 里回溯 16 层栈帧的平均耗时也需要 0.6ms,监控工具实际运行时对 App 性能的影响是很明显的。提升监控性能的途径除了直接优化栈回溯性能外,减少回溯频次也是十分有效的手段。我们在西瓜视频 App 的优化治理实践中发现,多数场景小于 1024 byte 的内存分配其频率约占 70% 以上,但线上遇到的 native 内存触顶问题,却很少是由小内存泄漏引发的,监控小内存泄漏对于解决线上 native 内存触顶问题没有实质效果。即便真的是由小内存引发的,这个需要高频和必现的场景才能达到,这类问题通常在线下单测(定向监控)场景是完全可以覆盖到的。基于此,Raphael 通过设定内存阈值来控制栈回溯频次,可以大幅降低栈回溯的性能损耗。减少无用栈回溯受限于代理流程和栈回溯实现机制,从代理函数入口到回溯开始的路径上会存在几层跟分配堆栈无关的函数调用,这几层调用最终会体现在最后回溯成功的堆栈上(下图的红色部分),每次内存分配都回溯这几层无用的调用链是十分损耗性能的。解决这种问题的直观方法就是减少甚至完全规避这种无关的栈回溯,体现在代码层面就是减少代理入口到回溯开启函数之间的调用层级。inline 是一种简单直接的实现方式,也可以直接在代理入口处提前构建回溯的 context 数据。缓存管理缓存管理作为 native 内存监控的重要一环,对整个监控工具性能的影响至关重要。以 malloc debug 和LeakTracer 为例,它们都是通过分配后的内存地址作为 key 来计算 hash 后散列存储的,并通过一个全局锁来同步缓存更新的时序。两者不同的是,malloc debug 会通过堆栈聚合调用链完全相同的内存分配记录,其缓存的存储单元通过 malloc 动态分配;而 LeakTracer 则不会根据堆栈聚合,其存储单元会预先分配一部分,缓存不足时也会动态申请。通过以上分析和实测可以发现,malloc debug 的实际性能比LeakTracer 低很多,原因主要体现在堆栈聚合和缓存动态分配上。对比 malloc debug 和 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 实现的,两类接口同时监控时会存在重入问题。栈内存释放线程的栈内存又分为信号栈和执行栈,信号栈在调用void pthread_exit(void *return_value)接口时会通过 munmap 即刻释放,而执行栈的释放则有两种形式:void pthread_exit(voidreturn_value) 函数体里,当线程状态为 THREAD_DETACHED 时会直接通过 void _exit_with_stack_teardown(voidstack, size_t sz) 释放int pthread_join(pthread_t t, void** return_value) 里通过pthread_internal_remove_and_free,最终在pthread_internal_free 里通过 munmap 释放综上,最终通过 munmap 释放的内存都可以被监控到,而通过_exit_with_stack_teardown 释放的内存则无法拦截到。我们针对这种情况做了特殊处理:在 Raphael 里代理拦截了 void pthread_exit(void *) ,并判断此时线程状态是否为 THREAD_DETACHED,如果是则在监控里直接移除相关记录,否则不移除。重入问题下图是一个典型的重入现场,其上层的 malloc 函数最终调用到了 mmap 函数,同时监控两类内存接口时就会遇到此类问题。重入问题带来的一个挑战是缓存如何管理,同一个缓存里只能维护一个记录,维护两个记录的逻辑和性能过于复杂。此外,从 malloc 到 mmap 的堆栈是固定的,这几层堆栈对分析内存泄漏完全没用,因为这个时候关注的是 malloc 之上的堆栈。解决重入问题的方案很直接,在检测到 mmap/mmap64 之上有 malloc/calloc/realloc 等栈帧时,忽略本次分配。这样不仅解决了重入问题,也避免了不必要的栈回溯。因为 Android 平台不支持 thread local storage(TLS),只能通过 pthread_setspecific 和 pthread_getspecific 实现。综合评估功能相对于malloc debug和LeakTracer,Raphael 不仅支持 malloc/calloc/realloc/memalign/free,也支持监控 mmap/mmap64/munmap 等,使监控范围扩展到了线程、webview、Flutter、显存等,基本完全覆盖了 Android 平台上的 native 内存使用场景性能Android 平台上的 native 内存泄漏检测通常都是在程序运行过程中进行的,栈回溯和缓存管理会消耗部分 CPU 和内存,带来一定的性能损失。Raphael 可配置的监控能力有很大的伸缩性,性能影响可以限制在可接受范围内,以下数据基于西瓜视频 App 32 位模式评测(中高端机型和 64 位下的性能更高):CPU:32 位模式 & ≥1024 的监控阈值下,在低端机上 CPU 消耗
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-12 09:46 , Processed in 0.551707 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表