此前我负责的 SDK 已集成多个司内业务,一切运行正常,最近在接入到一些游戏项目中的时候发现存在比较多关于 libc.so 的 crash,在游戏中某个场景会使用SDK 进行逻辑处理,在部分手机会在短时间就直接 Crash,且集中在性能比较好的手机中。经过一番折腾,最后被定位在了一个跟 SDK 没有什么关系的地方:pthread_key_t

Crash 表现

在 Crash 上报平台中收到诸多的 Crash 上报,调用的形式多种多样,异常名都是signal 6 (SIGABRT)

但崩溃调用栈最终都停留在
/apex/com.android.runtime/lib64/bionic/libc.so pc (abort+168)

以及中间都会经过:
/apex/com.android.runtime/lib64/bionic/libc.so (pthread_once+136)

难以复现的问题

由于我们的项目依赖于其他业务的SDK,最终的 SDK 打包合并在 Unity 的游戏中,我们不能直接使用游戏侧代码逻辑进行编译打包进行调试,这为问题的排查增大了一定的难度,只能在 Unity 的 demo 工程具体的表现为:

1、部分性能好的手机(如小米14 pro)才会出现 Crash,而且在对应的游戏中必现,有些游戏又不会复现

2、SDK里面同样的代码逻辑在测试 App 工程中完全不会复现

3、SDK里面同样的代码逻辑在 Unity 测试游戏 demo 中也完全不会复现

4、使用了业务方(游戏侧)的 Unity 的各种配置,依然没有复现

5、崩溃栈中有涉及到 thread 相关的关键词,怀疑是线程相关问题,但在原生层开辟N个线程也没有复现

6、其他各种尝试都没有复现:开辟大量内存、Unity 与 Android 调用方式调整……

解决线索与方案

一开始是怀疑业务方的环境与 SDK 运行环境有冲突,毕竟 SDK 已经在诸多业务中上线并正常运行了很久,不应该是 SDK 本身代码逻辑不对导致的才对。但没过多久,我们在另一个业务中也发现了这个问题,那说明并不是一个游戏环境导致。

解决问题直接看对应的崩溃栈,其崩溃栈都是使用相关的组件导致的 Crash,询问了相关的开发大佬之后并没有得到解决办法,原因是我们使用的版本相对较老,经历了比较久的迭代,逻辑改掉了很多。二是有可能这个问题在新版本中已经修复掉了。于是我们进行了一大波改造升级,经过一段时间后,再次集成到业务方,原以为这个问题就此解决了,调用了一下创编 SDK 之后依然 crash,此时心拔凉拔凉……
但这时候比较能确定的是,这个 crash 跟依赖的SDK 没有直接关系,可能是由其他的环境问题什么。

问题线索 pthread_key

在最开始的排查问题过程中一直在关注在环境的差异上面,经过一番折腾依然没有效果,方向错误了,于是再次回到 Crash 栈中来,在崩溃栈中都含有:pthread_onceemutls_get_addresscxa_get_globalsemutls_init相关的关键词,由于平时完全没有接触过这几个函数,对他们的了解比较少。但经过一番搜索之后,他们都有提到一个关键的术语:TLS (thread-local storage)

以及对几个函数调用的源码进行查看,发现这几个函数最终涉及到的都是 pthread 使用或者创建相关的

其中在 cs.android的 emutls.c 源码里有:

static void emutls_init(void) {
if (pthread_key_create(&emutls_pthread_key, emutls_key_destructor) != 0)
abort();
}

这里基本上可以和崩溃栈对应上了,正是这里执行的 abort(),那么原因是否是由 pthread_key_create()引起的呢?继续对 pthread_key_create 研究,原来在 Bionic 中,能够被开发者所使用的 Pthread Key 数量,是 PTHREAD_KEYS_MAX 宏所定义的 128 个。

那我们遇到的问题是否也是同一个问题呢?得到答案最好的方式是验证,想办法做一个验证,用代码把系统能提供的 pthread_key 耗尽然后再使用我们创编SDK的功能,使用如下代码创建 PTHREAD_KEYS_MAXpthread_key_t,再直接使用创编 SDK,果不其然 Crash了,而且 crash 栈与上报的数据比较的一致(没有完全一致,毕竟一些场景还是会有点差异)。

以下的代码会耗尽目前程序中的 key,只创建 pthread_key,而不释放掉

void available_key() {
for (int i = 0; i < PTHREAD_KEYS_MAX; i++) {
pthread_key_t key;
int result = pthread_key_create(&key, detachDestructor);
if (result == JNI_OK) {
__android_log_print(ANDROID_LOG_ERROR, "--julis", "create thread key Success");
} else {
__android_log_print(ANDROID_LOG_ERROR, "--julis", "create thread key failed");
}
}
}

从打印的日志里面看,在 Unity demo App 里面大概创建到 60 多的时候就创建失败了,也就是说Unity 本身可能就使用了很多 key,留给应用层开发的就只有几十个 key 了。

虽然尝试是Crash了,但怎么能证明这个就是导致业务方 Crash 就是这个原因呢?以及怎么解释有的手机为什么会Crash,有的手机不会Crash呢?

我们继续,从目前的推论来看,我们的创编SDK需要使用 pthread_key_t, 可能数量不够了,也就说创编SDK需要使用一定数量的key,那我们将刚才代码里面的 i < PTHREAD_KEYS_MAX; 进行调整,我们预留足够的 key 空间给创编SDK,i < target_number; 于是在之前 crash 的手机和未 crash 的手机做了一次对比。

以下是对部手机的测试结果,记录日志前面的数字就是代码里面的 target_number

从对比结果看,两部手机他们可以供应用层使用的 key 的数量是不同的,之前会 crash 的手机它可以使用的 key 明显是少于之前未 crash 手机的数量的,这也就能解释为什么有的手机为什么会 crash,有的手机不会 crash 了。以及,可以推测出来创编SDK使用了5个key左右。

这里提一下在解决问题之初,我们发现 crash 的手机基本上都是市面上比较好的手机,且手机的 GPU 都集中于 Adreno 比较新的型号,一度误以为是相关底层 SDK 未进行兼容性适配导致。为什么性能更好的手机使用的 pthread_key_t 会更多?猜测可能是好的手机 Unity 运行相关的东西或者优化(这里的优化指的是游戏特效或者功能玩法)更多,所以消耗的资源就更多一点,当然这里只是个人猜测,具体原因还需要深入了解。

还剩下一个问题:业务方的 App 为什么会Crash?于是将上面的 available_key()方法进行一次包装,并将其打包集成进游戏侧测试,从日志里面看到留给我们创编SDK使用的 key 只有3个了!而我们的 SDK 需要5个左右,问题原因基本就是这个了,那如何解决呢?

方案解决

究其根本原因是 Android 系统的 pthread_key_t 的使用数量的限制,那么最直接的解决方式那就是降低对 pthread_key_t 的使用,但是由于我们依赖使用其他地方的 SDK,对其项目直接优化更改可能成本相对较高,直接修改源码解决的话一时半会儿无法解决。这里先对 pthread_key_t 数量限制相关的问题进行一些研究总结:

在 Android 官方源码 pthread.h#pthread_key_create() 里面有提到:

There is a limit of PTHREAD_KEYS_MAX keys per process, but most callers should just use the C or C++ thread_local storage specifier anyway. When targeting new enough OS versions, the compiler will automatically use ELF TLS; when targeting old OS versions the emutls implementation will multiplex pthread keys behind the scenes, using one per library rather than one per thread-local variable. If you are implementing the runtime for a different language, you should consider similar implementation choices and avoid a direct one-to-one mapping from thread locals to pthread keys.
Returns 0 on success and returns an error number on failure.

int pthread_key_create(pthread_key_t* _Nonnull **key_ptr, void (* _Nullable **key_destructor)(void* _Nullable));

可以看到官方建议使用 thread_local 去实现 TLS,以及在新的系统版本中会使用 ELF TLSpthread_key_t 将不直接依赖,
但条件相对比较高,参考官方更新:需要 miniSDK>29 和NDK r26

ELF TLS (Available for API level >= 29)
Android supports ELF TLS starting at API level 29. Since NDK r26, clang will automatically enable ELF TLS for minSdkVersion 29 or higher. Otherwise, the existing emutls implementation (which uses pthread_key_create() behind the scenes) will continue to be used. This means that convenient C/C++ thread-local syntax is available at any API level; at worst it will perform similarly to “roll your own” thread locals using pthread_key_create() but at best you’ll get the performance benefit of ELF TLS, and the NDK will take care of the details.

最后我们的解决方式是依据上面 pthread_key_create 提到的

There is a limit of PTHREAD_KEYS_MAX keys per process…..

重点是:per process,每个进程有 PTHREAD_KEYS_MAX,这个PTHREAD_KEYS_MAX被定义在 limits.h 现在的 Android 基本上都是定义为128。那那我们将我们的SDK 使用的时候放在一个单独的进程不就ok了?事实是的,由于我们的SDK向业务只是提供一个 素材输入=>视频输出的功能,中间过程是一个黑盒,那么这个场景使用多进程是完全OK的,使用多进程还有一个好处就是能与游戏进程相独立,尽量减少两者之间的依赖。但多进程也带来了一些门槛,但这相比与改渲染 SDK 底层的源码来说是相对简单很多的,最终经过一番折腾我们将创编SDK得渲染放在了一个单独的进程,后试验运行在之前 Crash 过的游戏业务上一切正常。

pthread_key 检测工具

为了以后接入其他游戏前不再发生类似的Crash问题,在接入业务前做一些技术评估,pthread_key_t 可用数量可能也需要成为一个考量指标,可用数的不同,可能需要不同的技术方案,我专门写了一个小工具,可方便查询业务项目目前使用了多少 pthread_key_t,能帮助项目排查当前问题是否是由于 pthread_key_t 占满导致的相关问题。

不过我更想做一个能够检测项目里面有消耗过 pthread_key_t 的地方,将其 hook 住,打印出来对应的调用栈,这样就能方便业务排查。未来,随着 Android 业务的复杂化,这种问题可能会变成更多大型项目将会遇到。调研发现 Tencent 对外开源项目 Tencent/matrix 已经有针对 pthread_key 做了相关的hook,业务侧也可以直接使用 matrix 进行检测,但其项目相对比较庞大,以及使用的方式较复杂。于是将其精简到一个小工具内,整体大小只有1MB 不到。

源码地址:PthreadKeyDetect

总结

本文主要记录了i创作SDK出现大佬了关于 libc.so 的 Crash,经过调查,问题被定位在 pthread_key_t 资源耗尽的问题上,并对其进行了相关研究,最后并解决了该问题的过程。

参考

Android linker changes for NDK developers

thread specific key leakage

pthread_key_create用法导致的崩溃修复

Crash issue caused by pthread_key_create failed: 11 when integrating Flutter into our project #127079

Increase PTHREAD_KEYS_MAX