線程剖析 - 助力定位代碼層面高耗時問題
在當今的軟件開發領域,性能問題是一個永不過時的挑戰。為了解決這一挑戰,開發人員需要深入了解他們的應用程序運行時的性能,并快速定位高耗時問題。線程剖析是一種強大的工具,通過采集和計算運行時線程棧,可以幫助開發人員更好地理解和解決性能問題。本文將深入探討線程剖析的基本思想和實現思路,以及客戶端和服務端的設計。
一、基本思想
線程剖析的核心思想是在業務線程執行請求時創建一個特定閾值觸發的檢測任務,用于監測高耗時問題。如果任務未被取消,在達到高耗時閾值時,將有專門的線程去執行剖析任務,采集業務線程的堆棧,并異步發送給剖析服務端進行計算,以估算出棧上的各個方法耗時。這個工具不僅提供了詳細的性能數據,還能與開放遙測(OpenTelemetry)結合,從而實現鏈路特征的關聯,主要流程如下:
圖片
二、實現思路
客戶端設計
客戶端的架構主要體現在任務的創建、調度、執行和導出四個環節。
創建&調度任務
業務線程執行時,若滿足指定要監控的接口或線程名稱,將構造一個包含該線程對象的檢測任務放入隊列,時間輪的工作線程會周期性(默認100ms)在輪盤上移動一格,類似我們平時看到的鐘表上的指針那樣,每個周期會從任務隊列取出所有任務,將各個任務分配添加到時間輪中每個格子中。如下圖所示:
圖片
執行任務
分配完成后,由任務執行線程池的線程去執行當前周期所屬格子的所有任務。在執行前,業務線程可能優先結束而取消該任務的執行,例如在達到耗時閾值后,剖析任務已經或準備開始執行,但主線程取消了剖析任務這樣一個臨界點,此時可通過各語言的同步機制來及時取消剖析任務。
任務執行時,剖析線程將周期性采集線程的堆棧,而為了方便后續的分析工作,也會同時記錄當前堆棧產生的時間戳,直到業務線程發出中斷通知,或采集樣本數達到上限,或任務狀態發生改變,然后中斷剖析線程的執行。
執行完成后,將采集到的線程棧集 push 到診斷數據隊列,等待數據導出線程消費此隊列,并發送到服務端。這里需要注意,線程棧數據文本量一般不會太小,比如我們一個專門用于測試的應用,500ms 觸發的閾值下的 HTTP 接口,每次請求讓線程隨機 Sleep 5s 以內,當接口耗時超過 3s,單次剖析產生的棧文本大小在 200KB 以上,因此這里需要有個參數,來控制隊列默認長度,避免過多的堆棧快照擠兌內存。
整個任務執行流程如下圖所示:
圖片
數據預聚合&導出
預聚合工作將由獨立的工作線程消費診斷數據隊列后來做,即將多個線程快照合并為一個,降低網絡 IO 開銷。具體就是對于快照集中每個快照的棧幀,按照它的開始時間取快照集中相同棧幀的最小值,結束時間取快照集中相同棧幀的最大值這個規則進行聚合,流程如下圖所示:
圖片
而數據發送層就比較簡單了,采用高性能無鎖隊列 Mpsc, 使用 gRPC 協議發送到診斷服務端:
圖片
當然,為了降低業務系統的壓力,也可以將原始數據直接落盤,由外部獨立的采集器逐行采集然后發送到消息隊列。
服務端設計
圖片
服務端的架構主要考慮三個點:
數據接收
同 Otel 對 Trace 數據的處理思路類似,診斷數據發送請求需要快速地被響應,來減少客戶端因請求延遲導致發送隊列數據被丟棄的可能。因此,診斷服務端采用吞吐性能較好的 go 語言編寫,而請求涉及到跨語言調用,協議層上,綜合高效快速可靠因素,選用較成熟的 gRPC 協議進行通信。
數據接收并成功解析后,需異步將數據放入隊列,這里我們選用采用了多副本機制的 Kafka 消息中間件,來滿足診斷服務各模塊之間的解耦,同時也保證診斷數據不丟失 。
數據解析&加工
診斷剖析數據消費組會去消費隊列中的數據,將數據進行進一步解析,并且持久化處理,其中包括:
- a) 父子棧幀推導
客戶端的預聚合會將多個快照合并為一個,因此快照內的每個棧幀將擁有不同的起始和結束時間。由于 Java 的原始線程堆棧是無層級的結構,為了提高數據的可讀性,進一步降低高耗時問題定位發現的成本,因此需將已合并的堆棧進一步推導為包含父子棧幀的結構化信息,即從棧頂的第二個棧幀開始遍歷調用棧,若當前棧幀的快照開始和結束的時間范圍位于上個棧幀的左開右閉或左閉右開區間,則將當前棧幀設置為上個棧幀的子棧:
圖片
注意:
1. 一個 Java 線程的大部分調用棧形式本身就是個從 "Thread.run" 開始的嵌套,而每次快照時也無從得知層級信息,因此不考慮推導快照開始和結束時間完全一致的棧幀,將這些棧幀置為同級即可。
2. 使用線程的快照時間來推導還原父子棧和耗時仍然是個相對比較粗略的統計行為,其精度受到當前線程調用棧快照導出的耗時,以及每次快照的間隔耗時的影響,因此父子層級結果僅供參考,并不絕對等于實際調用的關系結果。
- b) 自身耗時計算
當已推導出父子棧幀關系后,可對結果集進行遍歷,計算自身耗時,計算規則如下:
- 從第二個棧幀開始,如果與上一個棧幀的快照開始和結束時間一致,則上個棧幀的自身耗時設為 0,否則會將當前棧幀的父棧幀(若存在)的自身耗時減掉上個棧幀的自身耗時。
- 如果當前棧幀是最后一個,則將當前棧幀自身耗時設為快照開始與結束的時間差,并且將當前棧幀的父棧幀(若存在)的自身耗時減掉當前棧幀的自身耗時。
- 如果當前棧幀有子棧幀,處理方式同上。
以上圖調用時序為例,根據以上規則得出的自身耗時計算示意圖如下:
圖片
- c) 數據持久化
當完成父子棧幀推導和自身耗時計算后,數據將持久化存儲,例如將數據存儲到 ClickHouse,供數據查詢端使用。
數據查詢
診斷剖析數據將以 HTTP API 形式對外提供查詢服務,例如可觀測性門戶系統,可根據線程名,鏈路 Trace ID, Span ID 等特征進行剖析數據的查詢。
[
{
"data": "YXQgc3VuLm5pby5jaC5Vd...",//線程剖析棧
"thread_name": "XNIO-1 I/O-1",//線程名稱
"thread_state": "RUNNABLE",//線程狀態
"trigger_millisecond": 500,//觸發閾值
"self_millisecond": 38,//自身耗時
"source_snapshot_count": 153//快照數
},
{
"data": "YXQgaW8udW5kZXJ0b3cuc2Vy...",
"thread_name": "XNIO-1 task-1",
"thread_state": "RUNNABLE",
"trigger_millisecond": 500,
"self_millisecond": 0,
"source_snapshot_count": 140
}
]
調用鏈關聯
線程剖析能結合 OpenTelemetry ,借助 OpenTelemetry Java Instrumentation 上下文的生命周期,從而關聯 Trace ID 、接口名等鏈路特征。
圖片
自身監控指標
線程剖析功能需要擁有較完善的自身監控,以便觀測復雜剖析流程下對業務系統潛在的性能影響。這些監控包括:
- 任務檢測隊列大小
檢測隊列用于給時間輪提供任務,該指標的大小給線程剖析的采樣,接口名,線程名稱等條件提供了一定參考。
圖片
- 任務釋放平均耗時
剖析任務的釋放將會中斷正在執行的剖析任務,其中涉及到剖析、數據狀態機的改變,線程的中斷。多線程情況下,需保證操作的原子性,如果任務釋放的平均耗時變長,則能反映當前業務系統 CPU 線程上下文切換效率下降。
圖片
- 正在執行剖析任務的個數
線程剖析是以線程為單位來執行的,通過觀測正在進行線程剖析的任務數,可反映出剖析功能繁忙的程度,以及幫助我們決策是否需要對同時剖析的任務數進行限制。
圖片
- 線程堆棧導出平均耗時
線程棧導出方法的平均耗時,如果該操作耗時顯著升高,且調用棧未有明顯變化,則代表性能惡化。
圖片
- 數據隊列大小
指待發送到服務端的數據隊列大小。
- 數據入隊速率
指待發送到服務端的數據入隊的速率。
- 數據合并平均耗時
數據發送前進行預聚合,將多個線程快照合并為一個,這個過程的平均耗時,該值可供剖析條件提供一定參考。
圖片
- 線程棧快照導出發送平均字節
線程快照發送的請求包平均大小。
圖片
- 數據導出速率
線程快照發送的速率。
圖片
對以上指標進行監控,也方便對相關參數進行調優,從而更好地在診斷剖析功能的完整性與服務性能之間做相關取舍。
三、結語
線程剖析為解決性能問題提供了有力支持。通過采集和分析線程棧信息,它能夠幫助開發人員定位應用程序中的高耗時問題,為性能優化提供關鍵信息。本文詳細介紹了線程剖析的基本思想和實現思路,以及客戶端和服務端的設計架構。其核心思想是通過創建特定閾值觸發的檢測任務,監測高耗時問題,并將采集到的數據異步發送到剖析服務端進行進一步計算和分析。
此外,線程剖析的自身監控指標,這些指標有助于更好地了解剖析功能的性能和繁忙程度,以便進行決策和調優。線程剖析不僅提供了性能數據,還可以與 OpenTelemetry 相結合,實現鏈路特征的關聯,從而更全面地理解性能問題。
總的來說,線程剖析可以幫助開發人員提高應用程序的質量和性能,快速定位性能問題,以確保應用程序的順暢運行,同時,也可以更有效地應對性能挑戰,提高應用程序的可維護性和性能。