记一次 Android 周期性句柄泄漏的排查

滴滴国际化外卖 Android 商户端正常迭代版本过程中,新版本发布并且线上稳定一段时间后,突然触发线上 Crash 报警。

记一次 Android 周期性句柄泄漏的排查_第1张图片

第一次排查发现是在依赖的底层平台 so 库中崩溃,经过沟通了解到其之前也存在过崩溃问题,所以升级相关底层 so 版本。重新发版后短期没有出现 Crash 大面积上报情况,只有零星上报,但不久后又发生了第二次大面积 Crash 上报。具体信息如下图所示:

记一次 Android 周期性句柄泄漏的排查_第2张图片

在定位分析问题的过程中收获很多,通过这篇文章分享该 Crash 的排查过程、问题根因以及一些经验总结,希望能为读者在遇到同类型问题时提供一些参考。    

排查流程

Crash 描述

Crash 的量级,从两次高峰期的峰值来看,集中爆发的峰值为每日50次左右,第二次爆发的峰值最高为173次。同时,Crash 和影响用户数量是一比一。

记一次 Android 周期性句柄泄漏的排查_第3张图片

两次大面积爆发的问题设备集中在华为的三款机型,运行时间都在14天左右,内存情况正常,线程情况正常,此时还没有相关线索指向句柄,所以这个时候没有关注句柄,如下图:

记一次 Android 周期性句柄泄漏的排查_第4张图片

记一次 Android 周期性句柄泄漏的排查_第5张图片

定位分析

从整体的 Crash 描述以及 Crash 统计平台的相关数据来分析,每隔14天就大面积爆发一次,可以确定是周期性问题,这种问题的排查难度较高。

根据上报的错误日志,明确其崩溃位置是在底层的 libpush.so 库,同时和其维护的同学沟通后发现依赖的 so 版本出现过问题,所以我们在第一次大面积上报之后,升级了底层 so 库版本。虽然当时增发版本后没有明显的 Crash 上报,但还是存在零星的 Crash 上报,这让我们放松了警惕,未对问题的根因进行定位,才导致了后面更严重的第二次爆发。

第二次爆发后,通过分析 Crash 统计平台上的173次 Crash 日志信息发现,Crash 代码地址都是“000000000007ce08”,如下图,由于静态库中的代码地址都是固定的,所以对底层 so 库进行了代码定位。

记一次 Android 周期性句柄泄漏的排查_第6张图片

我分析底层 so 库代码习惯是使用 IDA,通过 IDA 定位到的问题代码如下图:

记一次 Android 周期性句柄泄漏的排查_第7张图片

找到对应问题代码块,定位到直接原因是 fopen 文件返回空,fwrite 写入数据之前未做空判断。由于 fopen 是调用系统 API,系统 API 出现问题概率极小,所以一定是业务某些异常场景导致打开文件失败。

业务进行了哪种非法调用引发的异常场景?我们需要定位到问题场景的代码执行环境和具体的用户操作路径。此时只有完整复现这个问题,才能找到导致 fopen 失败的根因。

上面我们推测是周期性问题,与业务运营侧同学确认,没有周期性的活动发布,排除客观因素。

从 Crash 相关数据分析,除了定位到 libpush.so 的直接代码位置,没有太好的进展,所以根据对应上报高峰的时间段排查 top5 中的其它新增 Crash,发现其中一个 Crash 从上报时间、运行时间、机型几个纬度与直接 Crash 信息高度一致,大概率是同一个问题导致,查看对应的堆栈信息。

记一次 Android 周期性句柄泄漏的排查_第8张图片

综合定位到的底层 so 库的问题代码,分析原因是句柄超限后 fopen 打开失败导致为空,综合 App 长时间运行分析,句柄泄漏问题有14天(左右)的周期性共性条件,同时输出了占比top3问题机型的句柄上限都是1024。我们知道目前国内大多机型的句柄上限是10000+,不过由于我们自己的业务形态是基于定制设备的,定制设备更新换代较慢,机型较老,所以句柄上限较低,最终导致了问题主要集中在业务采购的定制系统设备,非定制的用户设备句柄虽然也会异常增加,但是在一个版本周期内是远远达不到句柄上限的,也就不会出现崩溃问题。

从以上信息推测崩溃问题是句柄泄漏导致超过系统上限,剩下的就是如何复现用户的操作路径和正向代码根因定位了。

问题复现

由于是句柄泄漏问题,输出打开的本地 fd 后,无法直接定位到具体 so。又因为是新版本新增问题,所以通过反向排除法,进行版本 diff,排查更新的代码。而业务代码未涉及句柄操作,故逐个进行依赖 SDK 还原,设备定时输出 fd 数量进行分析。

测试版本为线上有问题版本:

第一次测试记录:测试耗时:12h+,fd: 227 → 272

第二次测试记录:测试耗时:15h+,fd: 227 → 296

第三次测试记录:测试耗时:24h,fd: 227 → 313

记一次 Android 周期性句柄泄漏的排查_第9张图片

句柄数量明显增加。

测试版本为还原更新的SDK版本:

第一次测试记录: 测试耗时:15h,fd: 193 → 198

第二次测试记录:测试耗时:21h,fd: 193 → 204,切换过账号一次

第三次测试记录:测试耗时:39h,fd: 193 → 210

句柄数量无明显增加。

对比 SDK 还原前后两个版本运行的数据得出结论,句柄异常增加的根因在线上版本所依赖的3个 SDK。这时候只需再依次对比3个依赖 SDK 的数据,定位到具体的问题 SDK 是时间的问题。同时我们也将排查定位进展同步给各自相关基础 so 库维护同学,发现之前其中一个 so 库的历史版本存在过句柄泄漏问题,和相应同学沟通了解相关信息。通过梳理底层 so 的句柄泄漏调用逻辑,增加调用日志,并对设备句柄数量持续观察,最终发现 so 侧存在一个逻辑:6小时轮询打开19个句柄,但是打开后没有正常关闭释放

此时问题的根因已大概定位,为了加快复现,我们把6小时轮询时间缩短为2分钟,运行一段时间后程序句柄数量达到1024上限发生崩溃,崩溃日志与线上崩溃日志完全相同,正向从用户角度复现了该问题。同时通过有无问题的两个版本 so 跑数据,对比后也证明问题的产生是由当前 so 库导致。至此,问题直接原因以及根本原因都已定位,问题修复后发版上线,线上验证通过。

为什么问题会集中爆发在 fwrite 方法的调用上?原因是业务其中一个场景需要30s轮询调用某个操作句柄的 API,高频调用 fwrite。它不能定位根本原因,不过暴露了直接原因,这也说明内存泄漏和句柄泄漏可能会报在任何代码位置。这里也让我们初期排查问题时,偏离了方向。不过这个就是这篇文章最想强调的内容,也是最想解决的问题,当出现这类问题,作为RD,我们要重点关注些什么?来快速纠正方向,快速定位问题。

下面我们简单介绍一下句柄泄漏是什么?如何处理?如何预防?Android中都有哪些常见的句柄?有助于我们后期快速排查定位句柄相关问题。

什么是句柄泄漏

句柄泄漏,就是当打开的资源未被正常释放,导致资源不能关闭回收。因为系统会为每个进程规定最大文件描述符上限数,一般 Linux 系统的进程最大句柄上限为1024,不过现在比较新的 Android 系统,上限升到32768,我们可以通过 adb shell ulimit -n 来查看:  

e64932a8fd72bff9e159fb0e107e0f64.png

截图为vivo findx2的设备文件描述符上限数

程序存在句柄泄漏问题时,对应的资源句柄不会被释放,当达到上限时,程序崩溃,报出异常信息。一般的异常信息有

  • Could not allocate dup blob fd

  • java.lang.RuntimeException: Could not read input channel file descriptors from parcel.

  • abort message 'could not create instance too many filesœ

  • java.io.IOException: Cannot run program "logcat": error=24, Too many open files

  • "Could not allocate JNI Env: %s", error_msg.c_str()

  • "Could not open input channel pair"

如果上报的日志信息包含这种,那大概就是句柄泄漏导致了。

注意:句柄泄漏和内存泄漏这类问题不属于业务逻辑问题,是进程分配的资源耗尽,导致再次分配时无足够的资源进行分配,所以当程序运行时,达到对应的资源上限后,就算普通的代码依然会直接报错,这个时候,上报的日志就是对应的代码位置,这点容易误导我们排查线上问题。

如何解决句柄泄漏

上面也提过,句柄泄漏和内存泄漏是一类问题,这类问题崩溃后,Crash 的位置可能不会明确的标出是哪里出现问题,最终的 Crash 日志也可能是普通代码。

问题用户操作路径存在共性的情况(复现难度较低)

  • 确定问题用户操作路径的共性

  • 根据用户操作路径,反复操作进行句柄数量监控并本地保存

  • 输出句柄异常增长情况,查看异常增长的句柄

  • 排查业务上,涉及句柄分配的代码

问题用户操作路径无共性的情况(复现难度较高)

  • 通过diff问题版本前后的代码,确定涉及句柄分配的代码改动

  • 通过排除法进行逐一回退对比,进行句柄数量监控和分析

  • 通过工具(so 库可以借助 IDA)进行问题代码定位,辅助分析问题

  • 确定问题代码或者问题依赖,再深入定位具体代码

以上的结论是建立在没有其它辅助手段基础上,实际排查过程中,我们还可以通过 Crash 平台上的辅助信息、梳理底层 so 库代码逻辑、积极与 so 侧同学沟通等角度进行辅助定位,也可以加速定位到问题代码。

定位句柄泄露相关命令和代码

1.查看设备句柄上限:adb shell ulimit -n

2.输出进程句柄:

private void listFd() {
    String tag = "FD_TAG";
    File fdFile = new File("/proc/" + android.os.Process.myPid() + "/fd");
    File[] files = fdFile.listFiles();
    int length = files.length;
    MerchantLogUtils.logFd(tag, "fd length: " + length);
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < length; i++) {
        File f = files[i];
        String strFile = Os.readlink(f.getAbsolutePath());
                sb.append(strFile + "\n");
    }
}

句柄泄漏常见问题以及分类

1.Andorid 常见的句柄泄漏问题

  • HandlerThread 的使用,要记得 release

  • IO 流操作打开后要在 finally 中 close

  • SQLite 数据库操作要把 cursor 实例 close

  • InputChannel相关,即 WindowManager.addView反复调用时,记得 removeView

  • Bitmap 进行 IPC 

这是常见的 Android 句柄泄漏的点,具体的原理分析资料很多。

2.部分 Android 的文件描述符的具体分类,可以缩小排查范围

记一次 Android 周期性句柄泄漏的排查_第10张图片

如何预防和监控句柄泄漏

上面都是如何解决,其实更希望问题在线下或者灰度期间就暴露并且解决掉。

预防

  • 代码开发过程中,涉及句柄操作需“慎之又慎”,要经过充分的自测

  • 代码 CR 的 CheckList 增加“句柄相关代码的重点CR”,如上面介绍的 Android 常见的句柄泄露的场景

  • 版本需求改动涉及句柄创建时,QA 需重复多次操作对应路径,查看句柄情况

  • 测试粒度覆盖句柄,测试包定时输出句柄指标,达到对应的阈值后报警,端上同学介入排查句柄增长原因

  • 自动化测试中增加长时间运行时句柄数量的观测

观测

  • 线上定时获取设备句柄数量并且上报,建立句柄均值观测

  • 根据线上稳定期间的句柄数量均值,设置合理的报警阈值,及时感知到该类线上问题

总结

从 Crash 统计平台的相关数据可以看到,所有的 Crash 都在底层 so 库里,这类问题我们除了分析 Crash 统计平台提供的相关信息,是否还有其它可以辅助定位问题的手段呢?

  • 在全面定位过程中,也需要关注相同时间段的其他新增 Crash 信息,确认是否有相关性,如同为新增、机型、时间、地区、用户操作场景等等之间的相关性。

  • 通过对应的工具进行三方 so 的代码逻辑梳理,综合已有数据进行分析,如本次排查过程中使用 IDA 分析底层 so 库代码,定位到直接原因为 fwrite 之前未做空判断。由于系统 API 出现问题概率极小,如果之前处理过此类问题,综合 Crash 统计平台上的机型、运行时间等,从这就可以初步定位是句柄泄漏问题。

  • 积极同步进展以及所有可能的分析到底层 so 侧同学,我们定位到句柄泄漏,反向排除法定位问题过程中也同步到底层so同学,发现之前另一个底层so 解决过句柄泄漏问题,提前定位到问题 so,减少了问题定位的人力浪费。

  • 在与相关同学沟通时,要带有自己的分析和判断,有着重点的讨论,这样会极大地提升排查效率。

综上,当问题发生在平台底层库时,同时又无明显的用户操作路径,通过代码定位工具先尝试定位直接原因,再通过 Crash 平台上各方面的信息对比分析其相关性,配合测试数据,可以加快定位这类周期性问题。

你可能感兴趣的:(记一次 Android 周期性句柄泄漏的排查)