看了 《Android 的離奇陷阱 — 設置線程優先級導致的微信卡頓慘案》這篇文章,有沒有覺得原來大家再熟悉不過的線程,也還有鮮為人知的坑?除此之外,微信與線程之間還有很多不得不說的故事,下面跟大家分享一下線程還會導致什麼樣的內存問題。
[anon:thread stack guard page]
而這裡出現多少塊棧內存就說明存在過多少個線程。導致這樣的局面可能有兩種原因:
進程一直在創建線程,並且線程都不退出,導致線程的數量暴增
進程一直在創建線程,但線程都退出了,而棧空間卻沒有釋放
那麼如何確定這個案例是哪個原因導致的呢?如果我們可以知道應用當前一共有多少線程,再和 maps 中 [anon:thread stack guard page] entry 的數量比較,就能知道是哪種類型的泄漏了。若數量基本匹配,說明是線程數量過多了,而如果 entry 數量遠多於線程總數,那就是棧內存泄漏了。
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 limits 的 RLIMIT_NPROC 參數對應了當前用戶 uid 的最大線程數量2,Android 中基本上一個 應用就對應了一個 uid,因此這個閾值就可以認為是應用的最大線程數量
虛擬地址空間不足或者內核分配 vma 失敗等內存原因,導致創建線程時分配棧內存時 mmap 或 mprotect 調用失敗3
前兩者取決於廠商的配置,比如我手中的測試機 resource limits 閾值高達數萬,而現網有些用戶的機型則只有 500。
但對現在大多數手機而言,線程數量不太容易達到 thread-max 或者 resource limits 的閾值,通常是在還沒達到限制閾值,就因為上述第三個原因而創建線程失敗, pthread_create 將返回非 0 值 EAGAIN(),如下面 demo 所示:
[1]: proc(5) — Linux manual page: https://man7.org/linux/man-pages/man5/proc.5.html
[2]: getrlimit(2) — Linux manual page: https://man7.org/linux/man-pages/man2/getrlimit.2.html
[3]: AOSP: https://cs.android.com/android/platform/superproject/+/master:bionic/libc/bionic/pthread_create.cpp;l=227
如何監控過多的線程呢?
線上的問題當然不可能像 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_create 和 pthread_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: https://man7.org/linux/man-pages/man3/pthread_key_create.3p.html
異步的坑: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 開銷
時間開銷
pthread hook 的開銷主要來自 unwind stacktrace、IO 讀取線程名、STL 容器操作,不同性能的機器開銷會有所差異。
測試環境:紅米 Note7,Android 10,高通驍龍 660
測試步驟:創建 1000 次線程取平均耗時
測試結果:
平均創建線程耗時(ns)Hook 前290798.02 nsHook 後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 狀態的。
[6]:AOSP:https://cs.android.com/android/platform/superproject/+/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:https://cs.android.com/android/platform/superproject/+/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,
如果是 PTHREAD_CREATE_DETACHED,就可以直接移除該線程的記錄
否則是 PTHREAD_CREATE_JOINABLE,就不能移除記錄了,而是只設置線程已經退出的標識位,記錄需要等待調用 pthread_detach 或 pthread_join 時再移除。
最後在 dump 線程記錄時,所有的標記了退出的線程,就是泄漏了棧內存的線程。
寫在最後
watchdog 檢查和 pthread hook 都已經在微信中使用了不短的時間了,watchdog 上報的指標可以用來衡量每個版本發布後線程的使用情況是否有好轉或者惡化、是否有引入新的泄漏,而 pthread hook 則提供了足夠的線索用來推動解決問題,效率上也有了很大的提升。
另外還有一些可以改進的點,比如我們沒有 hook clone 這個 API,因此無法監控到使用 clone 創建的線程。但目前直接使用 clone 並且可能導致泄漏的場景比較少,所以暫時沒有支持。
pthread hook 相關代碼已經回流到 Matrix 中:
https://github.com/Tencent/matrix