一文講解端線程死循環的治理

架構互聯高可用 2024-04-05 06:34:33

引言

釘釘作爲一個用戶數超 7 億,服務企業與組織數超過了 2500 萬的企業級智能辦公平台,終端團隊一直將打造極致用戶體驗作爲我們的理念。對于 Android 設備來說,線程死循環問題可能會導致高耗電,界面卡頓,耗盡內存等性能問題。與此同時,死循環問題還具有隱蔽性高,黑盒測試無法感知的特點。針對潛在的線程死循環風險我們進行了專項的治理優化。本文旨在介紹釘釘 Android 團隊死循環檢測工具建設的思路和典型案例的修複曆程。希望通過此次分享,對同樣面臨類似死循環問題的團隊能夠有所啓發。

術語表

線程死循環檢測機制

線程死循環檢測是一項複雜的任務。死循環可以在代碼的任何地方發生,從簡單的邏輯錯誤到複雜的系統原因,均可能會導致死循環。因此檢測並診斷此類問題相當困難。釘釘自研的 ANRCanary 監控工具,基于死循環線程高 CPU 占用和堆棧相似的兩個特點,實現了精准的死循環檢測能力。

獲取線程 CPU 占用比

通過讀取 Android 系統 proc 目錄下面的 stat 文件可以獲取進程和線程的 CPU 耗時,如下所示:進程的 stat 信息

進程 ID 爲:11162

進程用戶態 CPU 耗時 101, 內核態 CPU 耗時 51 。

對于死循環來說,CPU 的占用主要體現在用戶態,所以我們只關注用戶態的 CPU 占用比就好。主線程的 stat 信息主線程的線程 ID 爲 11162, 與進程 ID 相同。主線程用戶態 CPU 耗時 12, 內核態 CPU 耗時 8 。則主線程用戶態 CPU 占用比爲:12 / 101 = 12%子線程 Jit thread pool 的 stat 信息

子線程的線程 ID 爲:11168,與進程 ID 不相同。

子線程用戶態和內核態 CPU 耗時均爲 0 ,則該子線程用戶態 CPU 占用比爲:0% 。如果想計算一段時間區域內的各線程 CPU 占用比,只需要根據進程和線程的 CPU 耗時差值,計算即可得出。通常可以將 CPU 占用比超過 10% 的線程標記爲高耗時線程。

堆棧比較方案

死循環線程還有一個特點就是當出現一個循環點時,線程堆棧的底部是永遠相同的。以查詢曆史數據過多的數據庫而導致死循環的案例堆棧爲例:如上圖所示,針對該線程在一定時間間隔內抓的三個堆棧,可以發現堆棧的相似度很高,則可以將其作爲死循環的重點懷疑對象。

CPU 占用和堆棧比較相結合

起因CPU 占用方案:基于 stat 文件可以知道一個線程占了比較高的 CPU ,但是有效信息只有線程名, 對應不上代碼,因此很難分析出耗 CPU 的根因。堆棧比較方案:能夠拿到堆棧信息,但是當線程發生阻塞時(比如讀 IO 或跨進程通信),堆棧相似度也很高,因此誤報率比較高。因此唯有將兩個方案結合起來,才能比較准確地拿到高 CPU 占用線程的堆棧信息,從而大大提高檢測結果的可分析性。結合方案結合的關鍵點爲:線程名。因爲兩個方案輸出的信息均包含線程名,因此可以用線程名進行關聯匹配。如上圖所示,完整版本的死循環線程檢測機制詳細說明如下:首先是線程 CPU 占用比獲取階段

先基于 stat 文件獲取進程和線程的 CPU 耗時。

等待一段時間後,再次抓取進程和線程 CPU 耗時。

計算所有線程的 CPU 耗時,超過阈值,則認爲屬于高 CPU 線程。如果沒有這類線程,則結束檢測流程。

保存所有的高 CPU 線程的相關信息,留給下一個階段使用。其次是堆棧比較階段

獲取所有的 Java 線程對象列表,並進行遍曆。

先對 Java 線程進行高 CPU 線程名匹配,如果匹配不中,不會進行堆棧比較,直接移除。

在一定時間內,針對剩余線程連續抓幾次堆棧進行堆棧比較,堆棧相似度低于阈值的線程會被移除。最後是輸出檢測結果通過篩選的線程就是疑似發生死循環的線程。將 CPU 占用比信息與線程堆棧信息合並之後,輸出檢測結果。

實際案例分享

釘釘團隊將以上解決方案整合進 ANRCanary 組件,完成線程死循環檢測能力的建設。下文將通過幾個典型案例,介紹釘釘在線程死循環問題治理上取得的收益。

案例 1:多線程操作 HashMap 線程死循環

本案例涉及 Java 中一個經典的死循環問題,即在多線程環境中操作 HashMap 有可能觸發無限循環的情況。 ANRCanary 抓到的上報信息如下:{ "case:1420548922":{ "name":"DThread-2", "threadCPURate":0.*, "threadStackList":[ "java.util.HashMap.put(HashMap.java:425)", "fde.f(SourceFile:299)", "iwx$1.doAfter(SourceFile:57)", "prw.parseData(SourceFile:23)", "prv.a(SourceFile:18)", "pru$1.run(SourceFile:56)", "com.***.threadpool.TaskRunner.call(SourceFile:750)", "java.lang.Thread.run(Thread.java:762)" ] }, "case:1961205280":{ "name":"DThread-19", "threadCPURate":0.*, "threadStackList":[ "java.util.HashMap.put(HashMap.java:425)", "fde.f(SourceFile:299)", "iwx$1.doAfter(SourceFile:57)", "prw.parseData(SourceFile:23)", "psc.a(SourceFile:342)", "com.***.im.cl.a(SourceFile:100)", "prz.query(SourceFile:2628)", "psh$12.onExecuteRpc(SourceFile:244)", "pri$1.run(SourceFile:143)", "com.***.threadpool.TaskRunner.call(SourceFile:750)", "java.lang.Thread.run(Thread.java:762)" ] }}從死循環信息來看:

兩個線程堆棧均爲高 CPU 線程,且最後都是在操作 HashMap。很明顯是踩到了 HashMap 多線程不安全的這個坑。

將對應 HashMap 的使用改爲 ConcurrentHashMap 之後,該問題得到解決。

案例 2:Lottie 動畫後台未停止導致高 CPU 消耗

ANRCanary 抓到的上報信息如下:"case:-1056518995":{ "name":"main", "threadCPURate":0.*, "threadStackList":[ "com.airbnb.lottie.LottieAnimationView.invalidateDrawable(SourceFile:189)", "com.airbnb.lottie.LottieDrawable.invalidateSelf(SourceFile:261)", "lb.onValueChanged(SourceFile:100)", "com.airbnb.lottie.animation.keyframe.BaseKeyframeAnimation.b(SourceFile:60)", "nj.a(SourceFile:427)", "com.airbnb.lottie.LottieDrawable$1.onAnimationUpdate(SourceFile:103)", "pb.c(SourceFile:88)", "pd.doFrame(SourceFile:96)", "android.view.Choreographer$CallbackRecord.run(Choreographer.java:785)", "android.view.Choreographer.doFrame(Choreographer.java:568)", "android.os.Handler.dispatchMessage(Handler.java:95)", "android.os.Looper.loop(Looper.java:136)", "android.app.ActivityThread.main(ActivityThread.java:5336)", "com.android.internal.os.ZygoteInit.main(ZygoteInit.java:689)" ]}從死循環信息來看:

發生死循環的是主線程。

主線程相對于整個 APP 進程,CPU 占用量很高。

從堆棧看是因爲 Lottie 動畫導致的。

經過本地驗證發現確實存在切換到後台 Lottie 依然在執行動畫的問題。

原因是因爲釘釘使用的 Lottie 版本太低導致,升級 Lottie 版本之後該問題得到解決。

案例 3:屬性動畫泄露導致高 CPU 消耗

初步分析 ANRCanary 抓到的上報信息如下:"case:-647468375":{ "name":"main", "threadCPURate":0.*, "threadStackList":[ "android.graphics.drawable.LayerDrawable.setAlpha(LayerDrawable.java:1364)", "android.animation.ObjectAnimator.animateValue(ObjectAnimator.java:990)", "android.animation.ValueAnimator.animateBasedOnTime(ValueAnimator.java:1349)", "android.animation.ValueAnimator.doAnimationFrame(ValueAnimator.java:1481)", "android.animation.AnimationHandler.doAnimationFrame(AnimationHandler.java:146)", "android.animation.AnimationHandler$1.doFrame(AnimationHandler.java:54)", "android.view.Choreographer.doCallbacks(Choreographer.java:1047)", "android.view.Choreographer.doFrame(Choreographer.java:914)", "android.os.Handler.dispatchMessage(Handler.java:100)", "android.os.Looper.loop(Looper.java:214)", "android.app.ActivityThread.main(ActivityThread.java:7659)", "com.android.internal.os.ZygoteInit.main(ZygoteInit.java:938)" ]}從死循環信息來看:

死循環發生在主線程。

主線程相對于整個進程,CPU 占用率非常高,大量搶占了子線程的 CPU 時間片。

堆棧裏沒有業務堆棧,因此暫時無法定位到問題代碼。只能看出是發生了屬性動畫泄露,需要增加監控能力。監控能力增強 ANRCanary 在感知到發生屬性動畫泄露以後,需要將發生泄露的屬性動畫揪出來。大致方案爲:

從系統 AnimationHandler 類入手,獲取到當前運行中的屬性動畫實例列表。

從屬性動畫實例中提取關鍵信息,並附加到 ANRCanary 日志中上報。能力增強後的 ANRCanary 抓到的上報信息如下:"case:-647468375":{ "attachInfo":{ "animatorList":[ { "duration":1200, "propertyList":[ { "clazz":"IntPropertyValuesHolder", "message":"alpha: 25 255 " } ], "repeatCount":-1, "target":"android.graphics.drawable.LayerDrawable", "viewPath":"TextView:recording|RelativeLayout:0|RelativeLayout:0" }, ... }, "name":"main", "threadCPURate":0.*, "threadStackList":[ "android.graphics.drawable.LayerDrawable.setAlpha(LayerDrawable.java:1364)", "android.animation.ObjectAnimator.animateValue(ObjectAnimator.java:990)", "android.animation.ValueAnimator.animateBasedOnTime(ValueAnimator.java:1349)", "android.animation.ValueAnimator.doAnimationFrame(ValueAnimator.java:1481)", "android.animation.AnimationHandler.doAnimationFrame(AnimationHandler.java:146)", "android.animation.AnimationHandler$1.doFrame(AnimationHandler.java:54)", "android.view.Choreographer.doCallbacks(Choreographer.java:1047)", "android.view.Choreographer.doFrame(Choreographer.java:914)", "android.os.Handler.dispatchMessage(Handler.java:100)", "android.os.Looper.loop(Looper.java:214)", "android.app.ActivityThread.main(ActivityThread.java:7659)", "com.android.internal.os.ZygoteInit.main(ZygoteInit.java:938)" ]}從附加信息來看,泄露的屬性動畫爲:

duration:該動畫時長爲 1200 毫秒。

propertyList:

clazz:屬性值的類型爲 int 。

message:屬性名爲 alpha,屬性變化值爲從 25 到 255。

repeatCount:循環次數爲 -1 ,即無限循環永遠不停止,難怪會發生泄露。

target:alpha 屬性所屬的對象類型爲:LayerDrawable 。

viewPath:

LayerDrawable 所屬的 View 類型爲 TextView。

該 TextView 的 viewId 爲 recording 。

該 TextView 的父輩節點均爲 RelativeLayout 類型。

基于 viewId 、動畫時長、屬性變化值等信息,快速定位到問題代碼,確認了導致泄露的原因並修複。

案例 4:定時任務執行異常導致死循環

本案例爲一個隱蔽且複雜的死循環問題,解決過程極具挑戰性,耗時甚長。初步分析 ANRCanary 抓到的上報信息如下:"case:-2147483648":{ "name":"Timer-0", "threadCPURate":0.*, "threadStackList":[ "android.os.MessageQueue.enqueueMessage(MessageQueue.java:577)", "android.os.Handler.enqueueMessage(Handler.java:662)", "android.os.Handler.sendMessageAtTime(Handler.java:631)", "android.os.Handler.sendMessageDelayed(Handler.java:601)", "android.os.Handler.postDelayed(Handler.java:429)", "de.executor(SourceFile:31)", "tm.query(SourceFile:268)", "mk.start(SourceFile:166)", "mk$1.run(SourceFile:93)", "java.util.TimerThread.mainLoop(Timer.java:555)", "java.util.TimerThread.run(Timer.java:505)" ]}

從死循環信息來看:

發生死循環的線程名爲:Timer-0 。

該線程相對于整個進程,CPU 占用率也很高。

死循環的原因看起來是某個消息隊列被打滿導致。

因爲 Handler 的消息隊列被打滿之後,每次 postDelayed 調用都要執行一次插入排序遍曆整個隊列。

監控能力增強經過翻閱代碼,確認代碼中有一個周期性任務。周期性任務的實現方式采用的是 Java 提供的 Timer 類。正常情況下應該不會導致消息隊列被打滿才對。 ANRCanary 在感知到消息隊列疑似被打滿以後,需要收集更多信息來進行確認情況。大致方案爲:

獲取當前進程全部的線程對象,逐個遍曆。

如果線程爲 HandlerThread 類及其子類,則包含有消息隊列,可以嘗試獲取其消息隊列長度。

如果消息隊列長度超過一定阈值,則可以判定爲消息隊列被打滿。

對消息隊列中的消息進行遍曆聚合,分析出 Top 級消息內容。

允許存在多個線程的消息隊列被打滿的情況。能力增強後的 ANRCanary 抓到的上報信息如下:"case:-2147483648":{ "attachInfo":{ "messageQueueList":[ { "repeatRate":1, "repeatSignature":"android.os.Handler|jlh", "threadName":"TaskHandlerThread", "totalCount":**** } ] }, "name":"Timer-0", "threadCPURate":0.*, "runTime":***, "threadStackList":[ "android.os.MessageQueue.enqueueMessage(MessageQueue.java:577)", "android.os.Handler.enqueueMessage(Handler.java:662)", "android.os.Handler.sendMessageAtTime(Handler.java:631)", "android.os.Handler.sendMessageDelayed(Handler.java:601)", "android.os.Handler.postDelayed(Handler.java:429)", "de.executor(SourceFile:31)", "tm.query(SourceFile:268)", "mk.start(SourceFile:166)", "mk$1.run(SourceFile:93)", "java.util.TimerThread.mainLoop(Timer.java:555)", "java.util.TimerThread.run(Timer.java:505)" ]}從附加信息來看:

repeatRate:消息隊列重複率爲 100%,說明均爲同一類消息。

repeatSignature:重複消息的 Runnable 類型混淆後爲:jlh,確實爲監控模塊的周期性任務。

threadName:消息隊列所屬的線程名爲:TaskHandlerThread

totalCount:消息隊列長度遠遠超出正常消息隊列的長度,確實被打滿。

runTime:進程存活時長並不大。正常消息隊列應該只有幾條消息,而不應該被打滿。看來 Timer 的間隔能力確實失效了,死循環問題是真實存在的。最終定位對于 Timer 的運行機制進行深入分析以後發現問題:private void mainLoop() { while (true) { TimerTask task; boolean taskFired; long currentTime, executionTime; // 1.從運行隊列中獲取頭部的周期性任務 task = queue.getMin(); // 2.獲取系統時間(關鍵點!!!) currentTime = System.currentTimeMillis(); // 3.獲取周期性任務期望執行時間 executionTime = task.nextExecutionTime; taskFired = executionTime <= currentTime; // 4.用兩個時間的比較結果,決定是否執行周期性任務 if (taskFired) { task.run(); } }}如以上代碼片段第 9 行所示,Timer 的間隔時間是基于系統時間戳的。也就是說,修改手機系統時間會影響 Timer 周期性任務的執行。寫一個簡單的 Demo 驗證以後得出結論:

如果往前修改系統時間,周期性任務將停止執行。

如果往後修改系統時間,間隔時間會失效,周期性任務開始連續不停地執行。至此終于定位到該死循環的根因。修改方案就是把 Timer 替換爲 ScheduleExecutorService 來實現周期性任務,將不受系統時間變更的影響。

總結

長久以來,線程死循環問題經常是偶現的,比較隱蔽難以發現,釘釘自研的 ANRCanary 通過將高 CPU 占用和堆棧比較相結合的方案,突破了線程死循環問題感知難,定位難的瓶頸。通過該監控能力,團隊持續對潛在風險進行優化治理,線程死循環問題基本得到根治。提升用戶體驗是一條漫漫長路,唯有從點滴做起,方能逐步做到卓越。

微信支付混沌工程實踐

幹貨 | 爲業務系統賦能,攜程機票最終行程系統架構演進之路

收藏|如何畫好一張架構圖?

本文由高可用架構轉載。技術原創及架構實踐文章,歡迎通過公衆號菜單「聯系我們」進行投稿

0 阅读:0

架構互聯高可用

簡介:感謝大家的關注