共计 7145 个字符,预计需要花费 18 分钟才能阅读完成。
01 背景
内存不足引发的 APP 解体通常称为 OOM(Out Of Memory),iOS 端无奈捕捉 OOM 异样,也得不到任何堆栈信息,给咱们排查和解决问题带来很多困扰。引起 OOM 的起因归根结底就是因为内存调配不合理引起的,尤其是 内存处于危险水位时单次内存调配过大引起 Jetsam 机制开始失效而杀掉过程,通过咱们线上数据监控,百度 APP 客户端单次内存调配超过 30M 的 case 很多。
针对这种潜在的引起 OOM 的隐患,咱们开发了一种大内存调配监控计划,充分利用线上监控劣势(丰盛实在的用户场景和用户门路)和线下流水线劣势(可获取更多的堆栈信息),其中线上环境除了性能实现外,还要重点思考稳定性,不能引入额定的性能问题,通过技术摸索咱们解决了此类难题,线上监控和线下流水线监控相结合实现对百度 APP 大块内存的监控。
02 技术计划综述
大块内存监控大体分为两个功能模块,缺一不可:
- 获取内存调配详情。判断单次内存调配是否超过阈值,若超过阈值,阐明是大内存调配行为;
- 获取堆栈信息。丰盛的堆栈信息可间接帮忙开发同学定位到产生大内存调配的具体代码,定位调配不合理的 case。
最终可通过优化内存调配不合理的 case,达到升高 OOM 率的指标。
03 获取内存调配详情
3.1 计划比照
对于获取 iOS 端每次内存调配信息,有如下解决方案:
- 通过 hook 内存调配函数 alloc 办法 获取,用 swizzle 办法实现 hook,存在的毛病是监控范畴不够全面,只能监控 OC 对象,不能监控 C/C++ 对象。
- hook 库 libsystem\_malloc 内存调配函数 malloc\_zone\_malloc、malloc\_zone\_calloc、malloc\_zone\_valloc、malloc\_zone\_realloc 来获取内存信息。这种计划对于 OC 对象和 C/C++ 对象都可监控,然而因为要 hook 零碎 C/C++ 办法 而不是 OC 办法,目前的技术条件须要应用 fishhook。
百度 APP 采纳的技术计划如下图所示,首先通过重置 libsystem\_malloc 库中的 malloc\_logger 函数指针获取内存流动详情(调配和开释两种流动),而后通过 Type 类型过滤出内存调配的流动,最初获取内存调配大小,该计划 可监控 OC 对象和 C/C++ 对象,对 iOS 框架零碎没有侵入性,没有用 fishhook 库所以没有对 mach-o 文件做任何批改,也没有 hook 任何底层分配内存零碎办法,从 APP 性能和品质角度来说是最好的抉择。
3.2 libsystem\_malloc 源码剖析
libsystem\_malloc.dylib 是 iOS 零碎虚拟内存治理的外围库之一,任何波及到 OC、C/C++ 对象的内存调配都会调用该库的 API, 由它去调用操作系统 Mach 内核提供的接口去调配或开释内存。具体来说 libsystem\_malloc 提供了 malloc\_zone\_malloc,malloc\_zone\_calloc,malloc\_zone\_valloc,malloc\_zone\_realloc,malloc\_zone\_free 五个 API 来实现内存调配和开释,在 iOS 零碎中所有波及到的内存流动都会调用如上接口,当咱们 App 过程须要创立新的对象时,如调用 [NSObject alloc],或开释对象调用 release 办法时(编译器会增加),申请先会走到 libsystem\_malloc.dylib 的上述函数。
Apple 已开源此库,从如下地址能够下载到源码:https://opensource.apple.com/…
void *malloc_zone_malloc(malloc_zone_t *zone, size_t size){MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);void *ptr;if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {internal_check(); }if (size > MALLOC_ABSOLUTE_MAX_SIZE) {return NULL;} ptr = zone->malloc(zone, size); // if lite zone is passed in then we still call the lite methodsif (malloc_logger) {malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0); } MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);return ptr;}
3.3 要害函数malloc\_logger
从源码中咱们发现 malloc\_zone\_malloc、malloc\_zone\_calloc、malloc\_zone\_valloc、malloc\_zone\_realloc、malloc\_zone\_free 五个 API,在每次调用 mach 内核函数进行内存调配和开释后都有如下函数调用:
if (malloc_logger) {malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
}
先判断 malloc\_logger 函数指针是否为空,如果不为空会调用上述函数,将内存流动的详细信息通过该函数传递进来,从源码剖析的角度来看这是 iOS 零碎提供的一个日志函数,具体函数定义如下所示:
typedef void(malloc_logger_t)(uint32_t type,
uintptr_t arg1,
uintptr_t arg2,
uintptr_t arg3,
uintptr_t result,
uint32_t num_hot_frames_to_skip);
extern malloc_logger_t *malloc_logger;
依据源码咱们对 malloc\_logger 函数的入参做如下剖析:
对于第一个 type 字段,不同函数都不同,具体值前面具体解释,此外,咱们发现 malloc\_logger 生命为 extern 类型,在 C ++ 中申明 extern 关键字的全局变量和函数能够使得它们可能跨文件被拜访。
3.4 通过重置 malloc\_logger 函数指针获取内存流动详情
在后面章节咱们说过 malloc\_logger 为 extern 全局变量,所以通过以下步骤能够重置该变量获取内存流动详情;
1. 引入 libmalloc 头文件 malloc/malloc.h
2. 定义函数 bba\_malloc\_stack\_logger,参数定义与源码定义完全一致,这样做的目标是避免实参传递的时候呈现类型和参数个数不统一的问题。
3. 先保留 malloc\_logger 函数指针的值到一个长期变量 origin\_malloc\_logger,目标是保留零碎原始调用办法,替换函数指针后还要调用此办法。
#import <malloc/malloc.h>
typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip);
// 定义函数 bba_malloc_stack_logger
void bba_malloc_stack_logger(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t backtrace_to_skip);
// 保留 malloc_logger 到长期变量 origin_malloc_logger
orgin_malloc_logger = malloc_logger;
4. 将 malloc\_logger 赋值为自定义函数 bba\_malloc\_stack\_logger,在定义函数中先调用原始的零碎办法 origin\_malloc\_logger,该办法的调用保障了本计划对系统没有侵入性,接下来做大块内存检测。
//malloc_logger 赋值为自定义函数 bba_malloc_stack_logger
malloc_logger = (malloc_logger_t *)bba_malloc_stack_logger;
//bba_malloc_stack_logger 具体实现
void bba_malloc_stack_logger(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t backtrace_to_skip)
{if (orgin_malloc_logger != NULL) {orgin_malloc_logger(type, arg1, arg2, arg3, result, backtrace_to_skip);
}
// 大块内存监控
......
}
通过下面四个步骤,malloc\_zone\_malloc、malloc\_zone\_calloc、malloc\_zone\_valloc、malloc\_zone\_realloc 每次内存调配完结后,调用如下函数,因为 malloc\_logger 当初不为空,具体值为 bba\_malloc\_stack\_logger,所以在 bba\_malloc\_stack\_logger 中能够获取内存调配流动详情。
if (malloc_logger) {malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);}
3.5 通过 type 类型过滤出内存调配详情
通过下面章节咱们晓得 malloc\_zone\_malloc、malloc\_zone\_calloc、malloc\_zone\_valloc、malloc\_zone\_realloc、malloc\_zone\_free 五个 API 都会调用 bba\_malloc\_stack\_logger,其中的 API 实现又各有不同,malloc\_zone\_malloc、malloc\_zone\_calloc、malloc\_zone\_valloc 代表内存调配,malloc\_zone\_realloc 代表内存先开释再调配,malloc\_zone\_free 代表内存开释,不同的 API 调用是通过入参 type 来辨别的,所以本技术计划通过 type 反解析来获取内存调配,过滤掉内存开释。
3.6 获取单次内存调配大小并判断是否超过阈值
依据源码咱们晓得 malloc\_logger 函数的入参 arg2,arg3 代表内存调配大小,不同 type 代表含意不同,具体见上面表格剖析。
接下来判断是否超过咱们设定的阈值的大小,在 iOS 端依据教训单次内次调配 8M 就是普遍认为的大块内存,当然这个值由服务端下发可灵便批改,客户端写个默认值即可,然而这个值不倡议很小,太小会屡次触发大块内存监控逻辑影响咱们手机 app 的性能,超过阈值大小就进入上面的环节获取堆栈信息。
04 获取堆栈信息
4.1 百度 App 采纳的技术计划
调用零碎办法 backtrace\_symbols 可间接获取堆栈信息,然而存在两个问题,第一、办法具备线程属性,必须要在获取堆栈信息的以后线程调用;第二、耗时重大,实测在中高端机 (iPhone8 以上) 有 30ms 耗时,在低端机 (iPhone8 以下) 有 100ms 的耗时。如果大块内存是在主线程调配的,上述耗时会引起主线程卡顿问题,故此计划无奈针在线上生产环境应用。
针对这个问题,百度 App 采纳的计划如下所示,联合客户端和服务端双端劣势,首先利用 dyld 库生成了 APP 所有库的起始地址和完结地址,将获取堆栈函数地址信息详情步骤获取齐全放在独立子线程中实现,在客户端拼接成 crash 日志格局,充分利用服务端做堆栈详情反解析操作,客户端只须要在专属子线程执行耗时较少的堆栈函数地址比拟操作即可,这样做不会影响所属线程任何操作,更不会引入性能问题,齐全克服了零碎办法的有余,具体操作流程如下图所示:
4.2 生成所有库的地址范畴(dyld)
生成 APP 中 mach- o 文件的所有库的信息,该信息包含库名称、库起始地址和完结地址,该操作次要利用 dyld 库函数在子线程实现,不会占用主线程和内存调配所属线程任何资源。dyld 库提供了丰盛的 api 能够获取上述数据,具体来说,\_dyld\_image\_count 可获取所有模块数目,dyld\_get\_image\_name 可获取模块名称,dyld\_get\_image\_header 可获取每个模块的起始地址,\_dyld\_get\_image\_vmaddr\_slide 获取单个模块的随机基址。
4.3 backtrace 获取堆栈地址
当检测到大块内存时,在分配内存所属线程调用 backtrace 办法获取堆栈函数地址,代码示例:
// 返回值 depth 示意实际上获取的堆栈的深度,stacks 用来存储堆栈地址信息,20 示意指定堆栈深度。size_t depth = backtrace((void**)stacks, 20);
那么 backtrace 耗时到底如何?通过实际数据证实,堆栈深度设置为 20,实测高端机耗时在 3ms 以内,对性能影响根本能够疏忽,此外,不是每次内存调配都须要调用 backtrace 获取堆栈,只有单次内存调配大小合乎大块内存规范才会去获取堆栈。
因而咱们在线上生产环境堆栈深度设置为 20 并且只对高端机凋谢,深度值太小获取的堆栈信息无限,太大会明显增加 backtrace 办法耗时,线上数据证实堆栈深度设置为 20 既能满足性能要求又能解析出正当的堆栈信息,在线下流水线场景下堆栈深度为 40 以获取更丰盛的堆栈信息,两个场景各自施展本人的劣势并互相补充。
4.4 获取每个地址详细信息
通过 4.3 步骤,咱们获取了堆栈地址,然而这个还不够,为了不便服务端间接能够解析出堆栈信息,咱们在客户端须要将堆栈地址拼装成下图所示堆栈格局(相似 Crash 堆栈),libsystem\_kernel.dylib 0x1b8a9dcf8 0x1b8a73000 + 175352,第一项是库名称,第二项是堆栈函数地址(十六进制),第三项是动静库的起始地址(十六进制),第四项是十进制偏移量,其中第二项堆栈函数地址是通过 4.3 步骤的 backtrace 获取的,上面的重点是获取每个地址对应的动静库名称和绝对于动静库起始地址的偏移量。
对于上述详细信息的获取,本技术计划将该操作齐全放在子线程去实现,因为咱们曾经在 4.2 构建好了每个库的起始地址和完结地址,只须要遍历一遍全量库,判断地址是否大于该库起始地址并小于该库的完结地址,那阐明该地址就是属于这个库,从而失去该地址的详细信息,堆栈地址和动静库其实地址做差值可获取偏移量信息。
4.5 atos 和 dsym 解析堆栈
通过后面的步骤生成了相似 crash 日志格局的堆栈,上报服务端后,最初在服务端通过 atos 命令和 dsym 文件就能够反解还原出对应的堆栈内容,如:
BaiduBoxApp 0x000000010ff0ceb4 +[BBAJSONSerialization dataFromJSONObject:error:] + 256
通过这种形式能够把耗时较高的符号还原工作放到服务器端,客户端只须要执行耗时较少的堆栈函数地址比拟操作即可,放在子线程队列执行,不会影响所属线程任何操作,更不会引入性能问题。
05 总结
本文次要介绍百度 APP 大块内存监控计划,目前在生产环境和线下流水线环境均已部署,通过该计划实现了如下三个指标:
- 升高 OOM 率:如果内存调配不合理,优化后对升高 OOM 率有帮忙;
- 数据摸底,让咱们明确晓得百度 APP 哪些场景有大块内存调配;
- 起到预防的作用,因为咱们有明确的监控机制,督促每个开发同学创立内存对象时采纳适量准则防止无节制调配。
06 参考链接
[1] libsystem\_malloc.dylib 源码
https://opensource.apple.com/…
[2] Mach- O 文档介绍
https://developer.apple.com/l…
[3] Mach- O 源码
https://opensource.apple.com/…\_HEADERS/mach-o/loader.h.auto.html
[4]fishhook
https://github.com/facebook/f…
——————END——————
举荐浏览:
百家号基于 AE 的视频渲染技术摸索
百度工程师教你玩转设计模式(观察者模式)
Linux 通明大页机制在云上大规模集群实际介绍
超高效!Swagger-Yapi 的机密
百度直播 iOS SDK 平台化输入革新