加入收藏 | 设为首页 | 会员中心 | 我要投稿 拼字网 - 核心网 (https://www.hexinwang.cn/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 服务器 > 搭建环境 > Linux > 正文

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

发布时间:2022-11-11 12:37:31 所属栏目:Linux 来源:
导读:  这篇文章,有没有觉得原来大家再熟悉不过的线程,也还有鲜为人知的坑?除此之外线程池linux,微信与线程之间还有很多不得不说的故事,下面跟大家分享一下线程还会导致什么样的内存问题。

  [anon:thread s
  这篇文章,有没有觉得原来大家再熟悉不过的线程,也还有鲜为人知的坑?除此之外线程池linux,微信与线程之间还有很多不得不说的故事,下面跟大家分享一下线程还会导致什么样的内存问题。
 
  [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 也会增长。除此之外,系统对线程的数量也是有限制的。
 
  系统从三个方面限制了进程的数量:
 
  前两者取决于厂商的配置,比如我手中的测试机 resource limits 阈值高达数万,而现网有些用户的机型则只有 500。
 
  但对现在大多数手机而言,线程数量不太容易达到 thread-max 或者 resource limits 的阈值,通常是在还没达到限制阈值,就因为上述第三个原因而创建线程失败, pthread_create 将返回非 0 值 EAGAIN(),如下面 demo 所示:

  [1]: proc(5) — Linux manual page:
 
  [2]: getrlimit(2) — Linux manual page:
 
  [3]: AOSP: +/master:bionic/libc/bionic/pthread_create.cpp;l=227
 
  如何监控过多的线程呢?
 
  线上的问题当然不可能像 demo 中这样简单,很多泄漏都是在线下环境比较难复现的
 
  一个比较好的手段应该像 crash 捕捉那样,能在线上获得第一现场的信息,根据这个信息就能快速定位解决大多数的泄漏问题。
 
  为此我们通过 watchdog 周期检查监控应用的线程数量的方式,提前暴露问题,当数量超过设定阈值后,上报线程信息,用于排查线程泄漏问题并建立相关指标。虽然简单,但是好用。

  而 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:可以查看 《》这篇文章的相关介绍
 
  在实践中,我们 hook 了 pthread_create 和 pthread_setname_np 两个接口。
 
  (1). 在 pthread_create 的 hook handler 函数中要做三件事:
 
  (2). pthread_setname_np 的 hook handler 除了调用原函数外则主要负责更新及过滤统计的线程的名字。
 
  [5]: pthread_key_create — Linux manual page:
 
  异步的坑: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_routine 和 arg 参数,使用 condition variable,让子线程的 routine 在 pthread_create_handler 执行完之后再执行。show me the code:

  Hook 开销
 
  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 状态的。
 
  [6]:AOSP:+/master:bionic/libc/bionic/pthread_exit.cpp;l=146?q=pthread_exit
 
  当了解了 pthread join/detach 的相关背景知识后,我们可以很容易在 demo 中复现出案例中的 case:

  这里既没有 detach 也没有 join,当线程执行完就退出了,但这时查看 /proc/[pid]/maps 就能发现,跟开篇的案例一样,内存中充斥着大量的栈内存没有释放,并且与线程的数量不匹配。
 
  我们可以在创建线程时就通过 pthread_attr_t 参数把线程设置为 PTHREAD_CREATE_DETACHED 状态,那么创建的这个线程就不需要再显式调用 pthread_detach 或 pthread_join 了,Android 的 Java 线程在创建的时候就设置了此状态7。
 
  [7]:AOSP:+/master:art/runtime/thread.cc;l=883?q=thread.cc
 
  如何定位栈内存泄漏呢?
 
  有了前面 pthread hook 的经验,这个问题变得非常简单,我们只需要顺手把 pthread_detach 和 pthread_join 两个 API 也一起 hook 了,在原有的 pthread hook 逻辑基础上,简单改动一下线程退出时的回调逻辑,就能统计出是哪些线程的栈内存泄漏了:

  在线程退出时,读取 pthread_attr_t 中线程的 detach state,
 
  最后在 dump 线程记录时,所有的标记了退出的线程,就是泄漏了栈内存的线程。
 
  写在最后
 
  watchdog 检查和 pthread hook 都已经在微信中使用了不短的时间了,watchdog 上报的指标可以用来衡量每个版本发布后线程的使用情况是否有好转或者恶化、是否有引入新的泄漏,而 pthread hook 则提供了足够的线索用来推动解决问题,效率上也有了很大的提升。
 
  另外还有一些可以改进的点,比如我们没有 hook clone 这个 API,因此无法监控到使用 clone 创建的线程。但目前直接使用 clone 并且可能导致泄漏的场景比较少,所以暂时没有支持。
 

(编辑:拼字网 - 核心网)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!