貨拉拉iOS司機端執行緒治理總結
司機組iOS 團隊,負責國內貨運司機端 iOS APP 開發,同時支撐國內 iOS 業務線的業務基礎架構的開發和維護。
背景介紹
- 由於在過去幾年,貨拉拉業務高速發展的同時,作為核心業務入口的司機端,同樣在以「快」為第一目標實現業務需求迭代,積累了較多的技術債(各項技術指標與業界優秀的app相比都差強人意),並且線上經常會收到司機反饋手機發燙,耗電,crash等等問題。
- 司機使用的手機相比使用者來說效能普遍較差,同時司機的線上時長較高(平均3.5小時),由於以上客觀原因的存在,給司機端效能優化帶來了巨大的挑戰。
綜上,執行緒治理專項應運而生,目的就是降低crash,手機發燙,耗電等問題,儘量給原本並不富裕的記憶體,雪中送炭。
問題分析
-
濫用使用全域性佇列,並且使用了佇列的預設優先順序
``` dispatch_async(dispatch_get_global_queue(0, 0), ^{
//TODO
}); ```
開發人員在需要開啟執行緒處理任務時,大多都採用了全域性佇列預設優先順序來處理,所以專案中積累了大量的全域性佇列預設優先順序,導致了一人幹活,全家圍觀。
-
大量不必要的執行緒切換
``` dispatch_async(dispatch_get_global_queue(0, 0), ^{
//loadData
dispatch_async(dispatch_get_main_queue(), ^{
//重新整理UI
});
}); ```
一般的業務處理中,用子執行緒確實可以提高任務處理效率,但是也不能忽視佇列切換帶來的效能損耗。如果loaddata,是較為耗時的操作,用子執行緒處理無可厚非,但是僅僅是為了讀取本地的一些簡單配置或者資料,而開啟執行緒,就有點多餘了。(這裡的loaddata,需要根據自身業務進行評估,是否有必要開啟)
-
在高併發場景,沒有控制併發,而使用了全域性佇列建立了大量執行緒
``` //實時獲取位置資訊 非同步
- (void)getDriverCurrentAddress:(void(^)(HLLAddressComponent *component))complete
{
dispatch_async(dispatch_get_global_queue(0, 0), ^{
__block CLLocation *location;
__block NSDictionary *regeoInfo;
//業務處理
});
} ```
多個業務請求需要依賴getDriverCurrentAddress非同步返回的資料,所以會導致,多個getDriverCurrentAddress併發,然而方法內部並未控制併發,而且還採用了全域性佇列預設優先順序,當業併發大的時候,這裡會偶現死鎖。
-
業務使用執行緒的不合理
``` dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSMutableArray<NSDictionary *> *imageArray = [NSMutableArray array];
for (NSDictionary *photoDict in readyUploadImageArray) {
// 上傳照片
}
}); ```
業務使用執行緒不合理,業務要求是所有需要上傳的圖片,併發上傳。實際上全域性佇列預設優先順序分配一個執行緒後,多個任務擠在一個執行緒,並未達到業務預期的目的。
-
執行緒死鎖引起的crash
當大面積出現psynch_cvwait,semwait_signal,psynch_mutexwait,psynch_mutex_trylock,dispatch_sync_f_slow等資訊時,可以初步判定為執行緒死鎖。比如:
當然優先順序反轉也會導致死鎖,具體來說,如果一個低優先順序的執行緒獲得鎖並訪問共享資源,這時一個高優先順序的執行緒也嘗試獲得這個鎖,它會處於 spin lock 的忙等狀態從而佔用大量 CPU。此時低優先順序執行緒無法與高優先順序執行緒爭奪 CPU 時間,從而導致任務遲遲完不成、無法釋放 lock。導致陷入死鎖 。
-
子執行緒重新整理UI引起的crash
子執行緒重新整理UI的問題,有比較具體的提示資訊,還是比較容易發現的。
-
執行緒安全引發的crash
由於多執行緒讀寫問題的crash比較隱祕,發現難,定位難,所以,當出現pthread_kill,_objc_release,malloc: error for object 0x7913d6d0: pointer being freed was not allocated等資訊時,可以初步判定為多執行緒讀寫問題。
僅僅光靠這些還是不夠的,如果沒有做特殊的佇列處理,還是要做大量的除錯,如果發現某處業務可能會被多個執行緒訪問時,也需要重點關注。
方案介紹
-
採取新的佇列管理和分配製度
全域性佇列預設優先順序dispatch_get_global_queue(0, 0)的濫用,導致了一人幹活,全組圍觀。當大量併發的業務使用了全域性佇列的預設優先順序時,會為此優先順序建立遠超CPU核數的執行緒,不僅讓CPU疲於奔命,同時還增加了造成執行緒死鎖風險,從而引發crash。
由於貨拉拉的業務特點,我們決定為不同的優先順序,建立與CPU核數相等的序列佇列,通過優先順序的合理使用和序列佇列的排程,充分利用時間片和多核的效率,同時不出現相關副作用的情況下實現多執行緒操作。
```
import
NS_ASSUME_NONNULL_BEGIN
@interface HLLQueuePool : NSObject
//與使用者互動的任務,這些任務通常跟UI級別的重新整理相關,比如動畫,cell高度,frame等UI的計算 extern dispatch_queue_t HLLQueueForQoSUserInteractive(void);
//由使用者發起的並且需要立即得到結果的任務,比如讀取資料(配置,使用者資訊等)來載入UI,會在幾秒或者更短的時間內完成 extern dispatch_queue_t HLLQueueForQoSUserInitiated(void);
//一些耗時的任務,比如複雜的組合的網路請求,圖片下載,上傳 extern dispatch_queue_t HLLQueueForQoSUtility(void);
//對使用者不可見,可以長時間在後臺執行,比如,拉取配置,地理位置上報,日誌上報等 extern dispatch_queue_t HLLQueueForQoSBackground(void);
//預設,不推薦作為首選使用 extern dispatch_queue_t HLLQueueForQoSDefault(void);
@end
NS_ASSUME_NONNULL_END ```
業務使用改動小,只需在原有基礎上根據業務特點,補充合理的優先順序即可。
``` dispatch_async(HLLQueueForQoSUserInitiated(), ^{
//垃圾機型,讀取data,可能會導致卡頓,所以加了個執行緒。
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:urlString]];
}); ```
-
梳理執行緒併發較大的業務進行重構
業務場景: 當司機端的業務請求依賴getDriverCurrentAddress非同步回撥的資料
當大量的業務併發,呼叫getDriverCurrentAddress時,getDriverCurrentAddress方法內部採用全域性佇列(預設的優先順序)生成大量執行緒去處理資料,從而造成死鎖或者執行緒資源耗盡,crash。
業務重構:
- 梳理業務,適當降低併發甚至規避併發。
- 當業務併發呼叫getDriverCurrentAddress時,如果有該業務資料快取,則直接返回,同時獲取新的資料並快取。如果沒有業務快取,則getDriverCurrentAddress內部只能有一個任務執行,其他的任務需等待回撥後一併返回。
-
執行緒使用的合理性評估與改造
多執行緒可以提高系統資源利用率,但是開啟多執行緒需要花費時間(90微妙)和空間(0.5兆),開啟的執行緒過多,CPU頻繁的在多個執行緒中排程會消耗大量的CPU資源,會導致個別執行緒無法完成任務而假死,並且容易造成資料同步和死鎖的問題,所以不要在系統中同時開啟過多的子執行緒。
執行緒使用的合理性評估標準:
- 不可預估完成時間的任務,比如圖片上傳下載,普通介面請求
- 計算量比較大的,比如加解密,資料計算和處理
- 有可能卡頓主執行緒的任務,比如UI的計算與渲染
- 如無必要,不要隨意開啟執行緒。
-
死鎖問題的重點攻堅
首先我們要了解執行緒的生命週期:
- 新建:例項化執行緒物件
- 就緒:向執行緒物件傳送start訊息,執行緒物件被加入可排程執行緒池等待CPU排程。
- 執行:CPU 負責排程可排程執行緒池中執行緒的執行。執行緒執行完成之前,狀態可能會在就緒和執行之間來回切換。就緒和執行之間的狀態變化由CPU負責,程式設計師不能干預。
- 阻塞:當滿足某個預定條件時,可以使用休眠或鎖,阻塞執行緒執行。sleepForTimeInterval(休眠指定時長),sleepUntilDate(休眠到指定日期),@synchronized(self):(互斥鎖)。
- 死亡:正常死亡,執行緒執行完畢。非正常死亡,當滿足某個條件後,線上程內部中止執行/在主執行緒中止執行緒物件
然後,由於死鎖問題比較隱蔽,通常很難發現從而去排查,我們只能通過在bugly和內部的crash系統上,分析堆疊資訊:
當發現執行緒大面積的堆疊出現了psynch_cvwait,semwait_signal,psynch_mutexwait,psynch_mutex_trylock,dispatch_sync_f_slow等資訊時,就可以大膽懷疑執行緒非正常原因阻塞,而導致的死鎖。
最後,因為執行緒是一把雙刃劍,不使用執行緒就不會造成死鎖,就需要根據堆疊資訊排查對應的業務:
- 鎖用的是否合理
- 執行緒的數量是否遠超平時的執行緒數量
- 是否使用了NSRecursiveLock,此遞迴鎖不支援多執行緒遞迴,因為會造成優先順序反轉
- 排查業務,執行緒長時間的阻塞,導致任務無法正常執行,也會造成死鎖
- SCNetworkReachabilityGetFlags,此方法只能在子執行緒呼叫,否則會造成主執行緒同步阻塞
-
子執行緒重新整理UI的重點排查與治理
為什麼子執行緒刷UI,只是偶現crash呢?因為在蘋果現有框架下,重新整理UI是一種執行緒不安全的操作,所以必須放在主執行緒。放在子執行緒,恰好競爭同一資源時,才會crash。
所以需要對以下場景,做統一檢查處理:
- h5互動的回撥
- 二方庫,三方庫的代理和回撥
- 通知
- kvo相關
- 介面回撥
因為通知和kvo的觸發和處理都在同一執行緒,如果子執行緒觸發,那麼就有可能子執行緒重新整理UI
-
執行緒安全問題的梳理與重構
執行緒安全問題的實質,就是多執行緒寫的問題,嚴謹的說,多執行緒讀並不會造成執行緒安全問題,因為只是讀取資料,並不會產生錯誤的結果,即使交錯執行讀取,最終結果也是正確的。
cpu讀寫記憶體是通過資料匯流排操作的,且只有一個。所以在涉及到多執行緒讀寫問題時,對所有的寫進行序列或者加鎖操作即可,不需要區分資料型別(雖然基本資料型別,多執行緒寫,不會有問題)。
簡單提一下鎖和序列佇列的區別,鎖中間的執行操作相當於是序列佇列。鎖的特點是,鎖定範圍越小越好,但是鎖會造成死鎖。gcd序列佇列,則不會有死鎖的問題。關於用法,仁者見仁。
最後,執行緒安全問題,甚至比死鎖問題還要頑固,頑強。由於祖傳程式碼的原因,不得不對多個業務大類,進行了重構,將資料模型進行了拆分,同時對寫這一塊做了鎖或者序列的操作。
長效機制的建立
執行緒問題比較頭疼,在業務迭代和重構的過程中比較容易出現,如何才能降低執行緒問題對業務和效能的影響呢?
-
建立執行緒數量監控預警體系
pthread庫中提供了一個用於監控執行緒建立、執行、結束、銷燬的內省函式。
typedef void (*pthread_introspection_hook_t)(unsigned int event, pthread_t thread, void *addr, size_t size);
在啟動時,可以選擇啟動監控,開始監控執行緒數量。
``` enum {
PTHREAD_INTROSPECTION_THREAD_CREATE = 1, //建立執行緒
PTHREAD_INTROSPECTION_THREAD_START, // 執行緒開始執行
PTHREAD_INTROSPECTION_THREAD_TERMINATE, //執行緒執行終止
PTHREAD_INTROSPECTION_THREAD_DESTROY, //銷燬執行緒
}; ```
通過執行緒狀態改變,來記錄執行緒數量。
``` void pthread_introspection_hook_t(unsigned int event,
pthread_t thread, void *addr, size_t size) { //建立執行緒,則執行緒數量和執行緒增長數都加1 if (event == PTHREAD_INTROSPECTION_THREAD_CREATE) {}
//銷燬執行緒,則執行緒數量和執行緒增長數都減1
else if (event == PTHREAD_INTROSPECTION_THREAD_DESTROY){}
} ```
預警上報
- 當執行緒數量大於設定的某一閾值時(各業務,根據自己的業務情況進行閾值設定,通常採用平均值),採取預警。
- 考慮到獲取執行緒還是比較耗費效能的,所以第一階段,在debug階段,通過控制檯預警,列印,看看使用情況和效果。
-
後續通過APM收集上報
-
子執行緒重新整理UI檢測
Main Thred Checker (Runtime Issue)
除此之外,需要在xcode中,新增一個斷點“Main Thred Checker (Runtime Issue)”
如果有UI崩潰,崩潰點就會出現在UI崩潰的位置。
除此之外,專案在執行時,也可以利用日誌重定向匹配Main Thread Checker:開頭的錯誤日誌彈框提示。
-
規範執行緒使用
-
業務所有的執行緒同一使用HLLQueuePool來進行排程,同時設定好和業務匹配的優先順序即可,不需要關心排程。
- 程式碼review。有執行緒相關的修改或者提交,需要說明,著重review。
覆盤& 總結
本方案於4月份開始落地上線至今,通過資料採集和分析:
- 涉及到的crash數量大約在26k左右,粗略計算降低了crash率萬分之8
- 執行緒的平均數量從之前的51.3,降低到現在的41.6,執行緒損耗大約是原來的81%,效能節省了大約18.7%
執行緒治理專項的目的,就是降低crash和效能損耗,從覆盤資料來看,crash修復情況和效能優化均符合預期。
本次主要從佇列的管理和分配,高併發業務的梳理和重構,執行緒使用的合理性評估與改造,執行緒相關crash的排查和修復,長效機制的建立幾個方面介紹了貨拉拉iOS司機端線上程治理方面的實踐 。
希望我們團隊遇到的問題以及解決的經驗,能夠在穩定性治理方面幫助到你。
- 貨拉拉移動端Abort異常監控實踐
- 2022|貨拉拉技術團隊精華推薦
- 貨拉拉SSL證書踩坑之旅
- 一種Android應用耗電定位方案
- 貨拉拉應用架構演進,堪稱單體落地微服務避坑指南
- 出行iOS使用者端卡頓治理實踐
- 貨拉拉貨運iOS使用者端架構優化實踐
- 貨拉拉客戶端通用日誌元件 - Glog
- 貨拉拉出行iOS使用者端啟動優化實踐
- 貨拉拉使用者 iOS 端卡頓優化實踐
- 貨拉拉 iOS 包大小優化探索與實踐
- 貨拉拉A/B實驗分流演算法實踐
- 貨拉拉 Android 模組化路由框架:TheRouter
- 貨拉拉iOS司機端執行緒治理總結
- 貨拉拉 Android H5離線包原理與實踐
- 微前端架構的幾種技術選型、從月薪600到進入鵝廠、得物多活架構設計之路由服務設計、 貨拉拉 Android 動態資源管理系統原理 | 醬醬的下午茶第 17 期
- 貨拉拉 Android 動態資源管理系統原理與實踐
- 貨拉拉H5離線包原理與實踐
- 貨拉拉Android穩定性治理
- 貨拉拉Android 包體積優化實踐