【转】【微信】线程:“你可能把握不住”—— Android 平台下线程导致的内存问题

292 阅读12分钟

yvesluo 微信客户端技术团队 2021年09月08日 12:24

看了 《Android 的离奇陷阱 — 设置线程优先级导致的微信卡顿惨案》这篇文章,有没有觉得原来大家再熟悉不过的线程,也还有鲜为人知的坑?除此之外,微信与线程之间还有很多不得不说的故事,下面跟大家分享一下线程还会导致什么样的内存问题。

[anon:thread stack guard page]

在分析虚拟内存空间耗尽导致的 crash 问题时,我们在 /proc/[pid]/maps 中发现了新增了不少跟以往不一样 case,内存中充满了大量这样的块:图片从 map entry 的名字与内存大小和权限可以看出,这是线程的栈。

而这里出现多少块栈内存就说明存在过多少个线程。导致这样的局面可能有两种原因:

  • 进程一直在创建线程,并且线程都不退出,导致线程的数量暴增
  • 进程一直在创建线程,但线程都退出了,而栈空间却没有释放

那么如何确定这个案例是哪个原因导致的呢?如果我们可以知道应用当前一共有多少线程,再和 maps 中 [anon:thread stack guard page] entry 的数量比较,就能知道是哪种类型的泄漏了。若数量基本匹配,说明是线程数量过多了,而如果 entry 数量远多于线程总数,那就是栈内存泄漏了。

下面我们针对上述两种 case 逐一进行分析。

case1: 线程不退出

线程是有限的系统资源,我们通常会使用线程池来复用线程,但使用了线程池并不意味着就能解决所有的线程使用问题,也并不是所有的业务场景都能使用线程池的,比如要求 looper 上下文的场景。

如果使用线程的逻辑出了 bug 导致意料之外的“野线程”出现并不断堆积,线程数量就有可能失控,即线程泄漏了。

我们知道,每个线程都对应了独立的栈内存。在 Android 中,默认创建一个 Java 线程需要占用大约 1M 的栈内存,如果是 native 线程,还可以通过 pthread_attr_t 参数为创建的线程指定栈的大小。不加限制地创建线程,会让本不充裕的 32 位地址空间雪上加霜。

线程数量过多除了可能导致上述案例中的栈地址空间占用间接触发虚拟内存的 OOM crash,更常见的是下面这样的 crash:

图片

那是不是升级到 64 位包,就没有问题了呢?答案是否定的。虽然 64 位包的虚拟地址空间很大,但是线程随着代码运行入栈,数据需要实际写入物理内存,应用的 PSS 也会增长。除此之外,系统对线程的数量也是有限制的。

系统从三个方面限制了进程的数量:

  • 配置文件 /proc/sys/kernel/threads-max 指定了系统范围的最大线程数量 1
  • Linux resource limitsRLIMIT_NPROC 参数对应了当前用户 uid 的最大线程数量2,Android 中基本上一个 应用就对应了一个 uid,因此这个阈值就可以认为是应用的最大线程数量
  • 虚拟地址空间不足或者内核分配 vma 失败等内存原因,导致创建线程时分配栈内存时 mmapmprotect 调用失败3

前两者取决于厂商的配置,比如我手中的测试机 resource limits 阈值高达数万,而现网有些用户的机型则只有 500。

但对现在大多数手机而言,线程数量不太容易达到 thread-max 或者 resource limits 的阈值,通常是在还没达到限制阈值,就因为上述第三个原因而创建线程失败, pthread_create 将返回非 0 值 EAGAIN() ,如下面 demo 所示:

图片

图片

[1]: proc(5) — Linux manual page: man7.org/linux/man-p…

[2]: getrlimit(2) — Linux manual page: man7.org/linux/man-p…

[3]: AOSP: cs.android.com/android/pla…

如何监控过多的线程呢?

线上的问题当然不可能像 demo 中这样简单,很多泄漏都是在线下环境比较难复现的

一个比较好的手段应该像 crash 捕捉那样,能在线上获得第一现场的信息,根据这个信息就能快速定位解决大多数的泄漏问题。

为此我们通过 watchdog 周期检查监控应用的线程数量的方式,提前暴露问题,当数量超过设定阈值后,上报线程信息,用于排查线程泄漏问题并建立相关指标。虽然简单,但是好用。

我们通过 ThreadGroup 可以获取到所有的 Java 线程:

图片

而 native 的线程4的数量可以通过读取 /proc/[pid]/status 中的 Threads 字段的值得到,另外在 Linux 中每个线程都对应了一个 /proc/[pid]/task/[tid] 目录,该目录下的 stat 文件记录了线程 tid、线程名等信息,我们可以遍历 /proc/[pid]/task 目录得到当前进程所有线程的信息。

[4]: Android 中的 Java 线程也是用 pthread 实现的,因此这里说的 native 线程实际也包含了 Java 线程

遍历 /proc/[pid]/task/[tid]/stat 文件会有比较多的 IO 操作,可以结合应用的实际情况设定阈值,超过阈值再进行 dump 。

这是微信某个版本的上报聚类结果:

图片

从聚类饼图可以看出,Top1 问题是 Camera?Handler  线程。

在代码中搜索线程名就能定位到创建线程的地方,这时再分析上下文代码很容易得出泄漏的原因——没有调用 HandlerThread#quit() 方法:

图片

仅有线程名信息的局限性

当 Top 泄漏问题都修复后,这时剩下头部问题变成了 Thread-?pool-thread-?com.tencent.mm 这类没有特征的线程,仅通过上报的线程名难以定位到具体的业务代码。此外我们在 native 创建的线程,如果没有对子线程设置名字,子线程就会继承父线程的名字。

以下方的上报结果为例:

图片

如果只是 Java 层的线程泄漏,我们可以插桩进行排查,但对比一下 JavaThreadCount 和 ProcessThreadCount 可以发现这个用户泄漏的线程是 native 线程。而微信中有 100+ 个 so,不可能靠 review 代码来排查。

Hook 方案

实现原理

如果我们可以拿到创建线程的 stacktrace,那这个问题就迎刃而解了。

Java 线程是通过 pthread_create 创建的,我们 native 的代码也是使用的这个 API。

在综合了性能开销和稳定性因素之后我们采用了 PLT/GOT Hook  + “导出表” Hook 的方式来拦截相关的系统函数,然后获取 Java 和 native 的 stacktrace。

PLT/GOT Hook 和 “导出表” Hook:可以查看 《快速缓解 32 位 Android 环境下虚拟内存地址空间不足的“黑科技”》这篇文章的相关介绍

在实践中,我们 hook 了 pthread_createpthread_setname_np 两个接口。

(1). 在 pthread_create 的 hook handler 函数中要做三件事:

  • 保证上层的调用语义一致性,并统计关键信息

    我们需要拿到 native 线程的唯一标识 pthread_t 对象作为 key 进行统计,所以首先 hook handler 还是调回原来的 pthread API

    图片

  • 获取 native 和 Java 层的 stacktrace 信息并统计

    对  unwind stacktrace 感兴趣的同学,可以查看这篇文章 《介绍一种性能较好的 Android native unwind 技术》。最初 Java 层 stacktrace 是 JNI 反射回 Java 获取的,这种方式会导致偶现 StackOverflow 问题,可以通过防重入的方式规避。目前也在灰度使用 matrix-backtrace 的方案,从 native 穿过 art trampoline 直接获取 Java stacktrace,避免执行权回到 libart 而涉及改变虚拟机状态。

  • 监听线程退出事件,移除对应线程的统计记录

    线程退出时机的监听是通过 pthread_key_create实现的,不了解的同学可以看 man page 的介绍5

(2).  pthread_setname_np 的 hook handler 除了调用原函数外则主要负责更新及过滤统计的线程的名字。

[5]: pthread_key_create — Linux manual page: man7.org/linux/man-p…

异步的坑:invalid pthread_t

在实现 hook 的基本逻辑之后我们发现,在使用 pthread_gettid_np 获取线程 tid 的时候,会偶发 crash:invalid pthread_t passed to pthread_gettid_np

导致这个问题的原因是我们在 pthread_create 的 hook handler 里面先调用了 pthread_create,而这个 API 会立即启动子线程,那么接下来的统计逻辑(跑在父线程)跟子线程的逻辑是没有时序保证的。

如果子线程很快就跑完了,这时才跑到父线程的统计逻辑,就有可能出现这个 crash。

图片

要解决这个问题也很简单,我们可以替换掉原来的 start_routinearg 参数,使用 condition variable,让子线程的 routine 在 pthread_create_handler 执行完之后再执行。show me the code:

图片

Hook 开销

  • 时间开销

    pthread hook 的开销主要来自 unwind stacktrace、IO 读取线程名、STL 容器操作,不同性能的机器开销会有所差异。

    测试环境:红米 Note7,Android 10,高通骁龙 660

    测试步骤:创建 1000 次线程取平均耗时

    测试结果:

    平均创建线程耗时(ns)
    Hook 前290798.02 ns
    Hook 后478744.74 ns

    在性能较差的机器上,hook 后依然保持在百微秒级别。线程创建并不像 malloc 等内存操作这么高频,因此对应用的整体性能并没有可感知的影响。

  • 空间开销

    每个未退出的线程都对应了一条存储记录,包括 tid、stacktrace hash、native pc,1K 以内的 Java stacktrace,假设有 500 个线程记录在案,需要的内存空间也不超过 1M。

Case2: 线程栈内存泄漏

至此,线程数量过多的问题已经有了监控、定位工具。但如果是线程的栈内存泄漏又要如何定位解决呢?

为什么栈内存也会泄漏?

不了解 pthread 的同学可能会感到困惑,线程都退出了,为什么栈内存还会泄漏呢?我们看一下 Linux man page 中的描述:

Either pthread_join(3) or pthread_detach() should be called foreach thread that an application creates, so that system resourcesfor the thread can be released.

system resources,其实主要就是指栈内存。只有 detach 状态的线程,才会在线程执行完退出时自动释放栈内存,否则就需要等待调用 join 来释放内存6,而使用默认参数创建的 pthread 都是 joinable 状态的。

当了解了 pthread join/detach 的相关背景知识后,我们可以很容易在 demo 中复现出案例中的 case:

图片

这里既没有 detach 也没有 join,当线程执行完就退出了,但这时查看 /proc/[pid]/maps 就能发现,跟开篇的案例一样,内存中充斥着大量的栈内存没有释放,并且与线程的数量不匹配。

我们可以在创建线程时就通过 pthread_attr_t 参数把线程设置为 PTHREAD_CREATE_DETACHED 状态,那么创建的这个线程就不需要再显式调用 pthread_detachpthread_join 了,Android 的 Java 线程在创建的时候就设置了此状态7。

如何定位栈内存泄漏呢?

有了前面 pthread hook 的经验,这个问题变得非常简单,我们只需要顺手把 pthread_detachpthread_join 两个 API 也一起 hook 了,在原有的 pthread hook 逻辑基础上,简单改动一下线程退出时的回调逻辑,就能统计出是哪些线程的栈内存泄漏了:

图片

在线程退出时,读取 pthread_attr_t 中线程的 detach state,

  • 如果是 PTHREAD_CREATE_DETACHED,就可以直接移除该线程的记录
  • 否则是 PTHREAD_CREATE_JOINABLE,就不能移除记录了,而是只设置线程已经退出的标识位,记录需要等待调用 pthread_detachpthread_join 时再移除。

最后在 dump 线程记录时,所有的标记了退出的线程,就是泄漏了栈内存的线程。

写在最后

watchdog 检查和 pthread hook 都已经在微信中使用了不短的时间了,watchdog 上报的指标可以用来衡量每个版本发布后线程的使用情况是否有好转或者恶化、是否有引入新的泄漏,而 pthread hook 则提供了足够的线索用来推动解决问题,效率上也有了很大的提升。

另外还有一些可以改进的点,比如我们没有 hook clone 这个 API,因此无法监控到使用 clone 创建的线程。但目前直接使用 clone 并且可能导致泄漏的场景比较少,所以暂时没有支持。

pthread hook 相关代码已经回流到 Matrix 中:

github.com/Tencent/mat…