转载

[iOS] 线上内存泄漏检测方案与结果

场景

监控线上用户的内存泄漏。用户无感知的情况下监控,不影响用户体验,包括内存,CPU,磁盘,网络流量。都有严格限制,用量过大用户一般都会发现的。

比如流量消耗过多会给用户感觉打开你这个APP,流量一下就没了,就不愿意再打开。CPU消耗过高,手机发烫掉电快。磁盘过多,在设置-用量页面可以看到每个APP的磁盘占用。

监控哪些内存

1.不监控 OC 对象的 alloc 方法。因为现在都是 ARC 方式管理内存,并且不打算支持 MRC,所以不监控 alloc 方法。而循环引用属于特殊的泄漏在下个版本做,经实验 alloc 底层没调用 malloc。

2.监控 malloc / free / malloc_zone_malloc 系列方法。

3.C++ 的 new / delete 运算符暂时没找到办法 hook。有人能hook new运算符麻烦告诉我,谢谢。

所以暂时只监控C层的 malloc 等16个内存管理的函数。

已知的手段:

  • 1.fishhook 能 hook C函数,那么能拿到所有 malloc / free 的参数和返回值

  • 2.腾讯OOMDetector 能遍历 堆,栈,寄存器 的指针变量的值(如果一块内存不是泄漏的,那么在内存中就能找到一个指针变量指向它,泄漏的内存当然就找不到指针指向它了)

  • 3.OOMDetector 在扫描泄漏的时候要挂起所有子线程2秒,线上会影响到用户体验。

    比如用户的网络请求出去了,恰好触发泄漏检测,界面要多转2秒的菊花,网络返回的数据才能刷新UI。退到后台才检测的话,有些实时性的业务数据也在后台上报,那么可能会导致这些数据整体偏后2秒。还有内存检测本身耗的内存和CPU都要尽可能低才行。

所以需要一个能检测线上内存泄漏且不影响业务和用户体验的方案。不挂起任何线程。

泄漏检测算法

我设计优化后的泄漏检测算法大致原理如下:

1.hook malloc / free 等16个内存管理函数,malloc 调用是非常频繁的,一旦 hook 后能形成非常高速的 malloc / free 流。

2.用个哈希表记录已经分配的内存块(key : 地址,value : (调用栈,块大小,计数器等等))

3.在hook后的 malloc / free 方法中能拿到申请和释放的地址。如果遇到 malloc 申请,向哈希表中插入一个key为该地址的元素。如果遇到 free 释放,在哈希表中删除一个key为该地址的元素。那么这个哈希表中就记录着当前进程中所有申请的内存块。

4.发起内存泄漏检测的时候,遍历内存中所有指针指向的地址,然后在哈希表中查,如果有该地址,那么对应的value的计数器加一,如果没有则跳过。遍历完了之后,查哈希表中所有元素的计数器,显然计数器为 0 的内存块就是泄漏的,没有一个指针指向他,因为如果有指针指向他,他的计数器会被加一。

发现泄漏后,把value中的调用栈,地址等等上报到后台,程序员根据调用栈就能找到相关代码进行泄漏修复。

几个重要的问题

1.发起内存泄漏检测的时候怎么办?

一个空项目的所有指针变量的地址大约有100多万个,遍历一次大约需要2秒,与此同时 malloc / free 流是非常高速的。

  • 腾讯OOMDetector的做法是挂起所有子线程,其实就是停止 malloc 流,然后再做泄漏检测。

    如果不停止 malloc 流会有这样的问题。内存遍历扫过A区域后,正好在A区域有个 指针P 新 malloc 了一块内存,该指针P也没泄漏,哈希表中也有这块内存,但新创建的内存在哈希表中的计数器是0,遍历已扫过了A区域不会再回来,那么后面判断的时候就会把这块内存判定为泄漏,但其实没有泄漏,他是你内存遍历扫过后才创建的。造成误判。所以他得挂起所有线程。

为了线上使用,肯定不能挂起线程去停止 malloc 流。我的做法是:利用两个哈希表,hashA 和 hashB,交替使用。

初始 malloc 流都进入 hashA 内,在触发内存泄漏检测前,先把 malloc 流切到 hashB 内,然后在 hashA 内判断泄漏的内存块,同时 free 流也要同时流入 hashA,hashB,因为可能在内存遍历的时候也有释放 hashA 中的内存块,要删除 hashA 中对应元素,不然会误判了。

检测方法还是看元素的计数器是不是 0,0则意味泄漏。检测完后把 hashA 中没有泄漏的块倒入hashB中,泄漏的块上报相关信息到后台。

下次再发起内存检测的时候,则把 malloc 流从 hashB 切到 hashA,A/B 交替切换使用即可。这做法无需挂起任何线程,可在异步线程执行,不干扰主线程。

2.多线程访问处理

是在调用 malloc 的线程插入哈希表还是统一 dispatch_async 到一个串行 queue 里插入哈希表?

在malloc原有线程获取数据并插入到哈希表可能会减慢原有的malloc流程,dispatch_async 到串行 queue 里操作哈希表几乎不影响原有线程的执行速度。

另外内存遍历的线程也要查哈希表,malloc / free 的线程也要查哈希表,那么只在统一的"串行queue"中操作哈希表,在多线程下就不会有问题。

3.地址太多导致内存只增不减怎么办

假设内存块有100万个,那么哈希表中就有100万个元素,这些元素本身就占很多内存。

在这里我设个最大值,假设线上只能用10MB内存来检测泄漏。一个哈希表元素大小是 300 byte,那么限制哈希表的最大元素个数为 10MB / 300byte = 35000 个,超过35000个元素后的内存块直接丢弃,不再记录。

那么会导致有些泄漏被错过,线上还怎么能用呢?

对于一个泄漏X,A用户错过了,B用户能捕捉到就行,灰度10万个用户,有一个用户能捕捉到泄漏X并上报到后台就行。不要求单个用户能捕捉完所有的泄漏,10万个用户有一个能捕捉到就行。

万一真有一个泄漏,10万个用户都没捕捉到怎么办?一个版本能捕捉到 200 个泄漏,已经够程序员修复一段时间发版本了,修复完这200个后,排在后面的泄漏自然会捕捉到,并排到前面来的。

测试开发期,配置不限制内存使用,那么所有泄漏都能捕捉到。

线上期,配置最大 10MB,那么可能会有错过的,依赖多用户,最终都能捕捉到。

4. dispatch_async调用100w次内存暴增怎么办

在内存地址遍历的时候,一个空项目的所有指针变量的地址大约有100多万个,一个地址要查询一次哈希表,所以要dispatch_async一次。dispatch_async 底层调了 calloc 导致内存增长,实测调100w次增加100MB左右,所以不能用GCD的dispatch_async了,另外尝试了 performSelector 到异步线程,这个方法导致的内存增加比 GCD 还高。

在找不到其他框架的情况下,自己手动实现了一个简单的 dispatch_async,创建一个pthread线程,线程内部写个死循环,用信号量阻塞,不占CPU时间,有任务的时候唤醒,执行任务,无任务的时候阻塞。任务加到 std::list 中,然后唤醒线程去执行。性能达标,不会有内存只增不减的问题。

结果

经过上述实现,已经在后台捕捉到了内存泄漏,并且对业务无影响,内存控制在5MB之内,在遍历内存地址的时候需要2,3秒CPU是100%的,但都是在异步线程。

该方法由本文作者 ck2016 设计。转载需联系作者同意。

代码暂时不开源,以后可能会开源的。

作者:ck2016

链接:https://www.jianshu.com/p/efb4e8ba2a7e

正文到此结束
Loading...