背景

Android 平台上长期存在一类发生在 app 调用 CookieManager.getCookie(String url) 过程中的 native crash,困扰着很多研发,也严重影响了用户体验。此类问题 Android 4.1-9.0 均有覆盖,基本都发生在启动阶段。西瓜视频上此类问题长期占据 Top 3 榜单之一,存在时间已相当久远。在 Top 10 的 native crash 中占比超过 40%,Native crash 整体占比>30%,影响用户比例>1‰(此类 crash 的用户占比);主要集中在 Android 4.2.2、4.4.2、8.1、9.0 等版本上,其他 Android 版本上也均大量此类问题。最为严重的是此类 crash 基本都发生在启动 2s 以内,严重影响西瓜视频 app 的用户体验。其典型堆栈截图如下:

Native 堆栈

Java 堆栈(有>50%的 crash 没有 Java 堆栈)

排查思路

此类 crash 堆栈中只有 so 及偏移地址信息,没有相应的函数名,又不是必现问题,很难直接定位到问题原因。所以排查的关键是先找到有明确函数名的堆栈,有了详细的函数信息,才能进一步通过相关的函数名对照 AOSP 源码分析定位出原因。

初步调查

出现问题的 Android 版本和机型虽分布极广(Android 4.x - 9.0),但绝大部分堆栈几乎没有任何 Crash 相关的核心函数信息。幸运的是通过梳理所有相关的 crash,发现 Android 4.2.2 上有一类 crash 有一个函数信息_ZN4GURLC2ERKSs(GURL::GURL(std::string const&))。

这类 crash 的堆栈跟上述问题是一致的,都是在 Java 层调用到 nativeGetCookie 时 native 层出现了 crash,堆栈也基本相同,可以判定是一类问题。拉取并分析 Android 4.2.2 GURL 相关的源码,发现 GURL 涉及到的代码也是非常广的,具体哪个环节哪一层调用了 memmove 函数有点儿大海捞针。

既然能搜到 GURL 相关,猜测似乎跟 URL 相关。于是线上做了个简单的实验,看看是不是 getCookie 时传入的 URL 的问题。通过 hook 应用层所有 CookieManager.getCookie 的调用发现,发生 crash 时均存在多个线程同时调用 CookieManager.getCookie,怀疑可能是线程安全问题。

仅有这些信息是不够的,如果能拿到 crash 时的函数名,问题才能被确认。再次梳理这类包含有 GURL 堆栈的 crash 时发现,果然存在这类堆栈( ZN8url_util20LowerCaseEqualsASCIIEPKcS1_S1)。

同时梳理出的这种有明确上层 crash 函数名的堆栈还有以下两种,均是 GURL 的构造函数执行过程中出现的 crash,这其中有一类是 vector 相关的操作异常(vector 是非线程安全,这个本人印象很深刻,AOSP 源码里存在很多这类 vector 线程安全的问题:如 RenderNodeAnimator 等),这类异常也进一步加深了线程安全问题的怀疑。

深入分析

ZN8url_util20LowerCaseEqualsASCIIEPKcS1_S1 的原形是 url_util::LowerCaseEqualsASCII(char const*, char const*, char const*) ,这个堆栈跟前述问题是基本一致的,都是 crash 在 GURL::GURL(std::string const&)的构造函数调用链上,只是 crash 的原因不同。虽然不能简单判定为是同一类问题,但种种迹象表面就是同一类问题。这个堆栈有明确的 crash 时的函数名,通过这个问题或许可以发现问题的根本原因。

根据 PC=5cb1453e 发现,crash 是因为 R2 寄存器里为空(0x0)导致的,结合 DoLowerCaseEqualsASCII 的源码可以判定 R2 寄存里存的正是函数的第三个参数 b,这说明 crash 是因 b 为 null 导致的。

确认了 crash 的原因,再结合源码发现调用 url_util::LowerCaseEqualsASCII(char const*, char const*, char const*) 的且堆栈在 GURL 构造函数的调用链上的有两处,一处是下图里的 CompareSchemeComponent 函数,另一处是 DoIsStandard 函数,相关源码截图如下:

第一处 CompareSchemeComponent 函数的第三个参数正是 LowerCaseEqualsASCII 的第三个参数,但这个参数 kFileScheme 是个常量,不可能为 null,所以首先排除嫌疑。

第二处 DoIsStandard 里的 LowerCaseEqualsASCII 的第三个参数是个全局变量,是在 InitStandardSchemes 里初始化的,仔细分析 InitStandardSchemes 的源码可以发现,standard_schemes 虽然是个全局变量,但采用的是懒加载的方式初始化的。 那么问题来了,这个初始化过程/全局变量是线程安全的吗?

很遗憾这个函数并没有加锁,vector 也不是线程安全的,当然 std::vector<const char*>* standard_schemes 也就不是线程安全的。多个线程同时调到这里的话就会出问题,当有线程正在初始化 standard_schemes 时,另一个线程可能也在执行初始化,这时会出 vector 操作的同步问题;同样的,当一个线程正在遍历 standard_schemes 时,另一个线程可能给 standard_schemes 重新设置了新的值,这时候就会有机率触发空指针问题。

查阅 chromium 源码发现 Android 4.0-9.0 里依赖的源码均存在 GURL 初始化的线程安全问题,该问题存在时间已经相当久远,好在是已在 2019.05.21 提交了相关修复(Make //url initialization thread-safe, https://source.chromium.org/chromium/chromium/src/+/0ef8191485b6327872bf7f644ee8c2fb4861bb4a?originalUrl=https://cs.chromium.org/ )。但远水解不了近渴,市面上 Android 10 以内的老版本 chromium 仍存在此类问题,依赖系统升级最终解决此类问题是遥不可及的,为了不影响体验需要应用层主动修复或者采取措施规避。

修复方案

通过上述的分析可知,只需要保证 standard_schemes 在初始化完成前不会有第二个线程执行同样的逻辑即可。虽然没有系统层面的同步方案,但问题抛出的点都集中在应用层的同一处,在这个位置加个同步限制即可解决!不过为了保险起见还是在应用层做个全局防范(第一个执行完成之后放开限制)。西瓜视频 app 是通过自研的 AOP 工具 hook 应用层所有 CookieManager.getCookie(String url)的调用。

此方案在西瓜视频 app 432 版本灰度&全量上线后,再无此类问题,用很小的成本彻底解决了这类问题。

总结

调查 Native 问题时符号表信息是不可或缺的,大多数情况下可能缺少关键的符号信息,这给调查 Native 问题增加了很高的难度。但由于 Android 系统更新迭代的版本很多,加上厂商定制的差异,一些小众机型或 Android 版本的 crash 可能携带着关键的符号信息,这些往往就是突破点,排查问题时小众问题也应得到足够的重视。我们同时呼吁手机厂商尽量保留一些关键的符号表信息,为开发者保留一些可以方便定位问题的关键信息。

此外,虽然 http://androidxref.comhttps://cs.android.com 都可以在线查阅源码,但这两处的Android版本并不全。 https://android.googlesource.com 这里可以下载到几乎所有版本的源码,本地通过 Sublime 分析源码也十分方便(可以直接显示和跳转到方法的定义&引用位置)。

欢迎关注「字节跳动技术团队」

相关文章