貨拉拉iOS司機端執行緒治理總結

語言: CN / TW / HK

hll.png

司機組iOS 團隊,負責國內貨運司機端 iOS APP 開發,同時支撐國內 iOS 業務線的業務基礎架構的開發和維護。

背景介紹

  • 由於在過去幾年,貨拉拉業務高速發展的同時,作為核心業務入口的司機端,同樣在以「快」為第一目標實現業務需求迭代,積累了較多的技術債(各項技術指標與業界優秀的app相比都差強人意),並且線上經常會收到司機反饋手機發燙,耗電,crash等等問題。
  • 司機使用的手機相比使用者來說效能普遍較差,同時司機的線上時長較高(平均3.5小時),由於以上客觀原因的存在,給司機端效能優化帶來了巨大的挑戰。

綜上,執行緒治理專項應運而生,目的就是降低crash,手機發燙,耗電等問題,儘量給原本並不富裕的記憶體,雪中送炭。

問題分析

  1. 濫用使用全域性佇列,並且使用了佇列的預設優先順序

``` dispatch_async(dispatch_get_global_queue(0, 0), ^{

   //TODO

}); ```

開發人員在需要開啟執行緒處理任務時,大多都採用了全域性佇列預設優先順序來處理,所以專案中積累了大量的全域性佇列預設優先順序,導致了一人幹活,全家圍觀。

  1. 大量不必要的執行緒切換

``` dispatch_async(dispatch_get_global_queue(0, 0), ^{

//loadData

dispatch_async(dispatch_get_main_queue(), ^{

  //重新整理UI

});

}); ```

一般的業務處理中,用子執行緒確實可以提高任務處理效率,但是也不能忽視佇列切換帶來的效能損耗。如果loaddata,是較為耗時的操作,用子執行緒處理無可厚非,但是僅僅是為了讀取本地的一些簡單配置或者資料,而開啟執行緒,就有點多餘了。(這裡的loaddata,需要根據自身業務進行評估,是否有必要開啟)

  1. 在高併發場景,沒有控制併發,而使用了全域性佇列建立了大量執行緒

``` //實時獲取位置資訊 非同步

  • (void)getDriverCurrentAddress:(void(^)(HLLAddressComponent *component))complete

{

dispatch_async(dispatch_get_global_queue(0, 0), ^{

__block CLLocation *location;

__block NSDictionary *regeoInfo;

   //業務處理

});

} ```

多個業務請求需要依賴getDriverCurrentAddress非同步返回的資料,所以會導致,多個getDriverCurrentAddress併發,然而方法內部並未控制併發,而且還採用了全域性佇列預設優先順序,當業併發大的時候,這裡會偶現死鎖。

  1. 業務使用執行緒的不合理

``` dispatch_async(dispatch_get_global_queue(0, 0), ^{

 NSMutableArray<NSDictionary *> *imageArray = [NSMutableArray array];

for (NSDictionary *photoDict in readyUploadImageArray) {

  // 上傳照片

}

}); ```

業務使用執行緒不合理,業務要求是所有需要上傳的圖片,併發上傳。實際上全域性佇列預設優先順序分配一個執行緒後,多個任務擠在一個執行緒,並未達到業務預期的目的。

  1. 執行緒死鎖引起的crash

當大面積出現psynch_cvwait,semwait_signal,psynch_mutexwait,psynch_mutex_trylock,dispatch_sync_f_slow等資訊時,可以初步判定為執行緒死鎖。比如:

當然優先順序反轉也會導致死鎖,具體來說,如果一個低優先順序的執行緒獲得鎖並訪問共享資源,這時一個高優先順序的執行緒也嘗試獲得這個鎖,它會處於 spin lock 的忙等狀態從而佔用大量 CPU。此時低優先順序執行緒無法與高優先順序執行緒爭奪 CPU 時間,從而導致任務遲遲完不成、無法釋放 lock。導致陷入死鎖 。

  1. 子執行緒重新整理UI引起的crash

子執行緒重新整理UI的問題,有比較具體的提示資訊,還是比較容易發現的。

  1. 執行緒安全引發的crash

由於多執行緒讀寫問題的crash比較隱祕,發現難,定位難,所以,當出現pthread_kill,_objc_release,malloc: error for object 0x7913d6d0: pointer being freed was not allocated等資訊時,可以初步判定為多執行緒讀寫問題。

僅僅光靠這些還是不夠的,如果沒有做特殊的佇列處理,還是要做大量的除錯,如果發現某處業務可能會被多個執行緒訪問時,也需要重點關注。

方案介紹

  1. 採取新的佇列管理和分配製度

全域性佇列預設優先順序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]];

}); ```

  1. 梳理執行緒併發較大的業務進行重構

業務場景: 當司機端的業務請求依賴getDriverCurrentAddress非同步回撥的資料

當大量的業務併發,呼叫getDriverCurrentAddress時,getDriverCurrentAddress方法內部採用全域性佇列(預設的優先順序)生成大量執行緒去處理資料,從而造成死鎖或者執行緒資源耗盡,crash。

業務重構:

  1. 梳理業務,適當降低併發甚至規避併發。
  2. 當業務併發呼叫getDriverCurrentAddress時,如果有該業務資料快取,則直接返回,同時獲取新的資料並快取。如果沒有業務快取,則getDriverCurrentAddress內部只能有一個任務執行,其他的任務需等待回撥後一併返回。
  1. 執行緒使用的合理性評估與改造

多執行緒可以提高系統資源利用率,但是開啟多執行緒需要花費時間(90微妙)和空間(0.5兆),開啟的執行緒過多,CPU頻繁的在多個執行緒中排程會消耗大量的CPU資源,會導致個別執行緒無法完成任務而假死,並且容易造成資料同步和死鎖的問題,所以不要在系統中同時開啟過多的子執行緒。

執行緒使用的合理性評估標準:

  1. 不可預估完成時間的任務,比如圖片上傳下載,普通介面請求
  2. 計算量比較大的,比如加解密,資料計算和處理
  3. 有可能卡頓主執行緒的任務,比如UI的計算與渲染
  4. 如無必要,不要隨意開啟執行緒。
  1. 死鎖問題的重點攻堅

首先我們要了解執行緒的生命週期:

  1. 新建:例項化執行緒物件
  2. 就緒:向執行緒物件傳送start訊息,執行緒物件被加入可排程執行緒池等待CPU排程。
  3. 執行:CPU 負責排程可排程執行緒池中執行緒的執行。執行緒執行完成之前,狀態可能會在就緒和執行之間來回切換。就緒和執行之間的狀態變化由CPU負責,程式設計師不能干預。
  4. 阻塞:當滿足某個預定條件時,可以使用休眠或鎖,阻塞執行緒執行。sleepForTimeInterval(休眠指定時長),sleepUntilDate(休眠到指定日期),@synchronized(self):(互斥鎖)。
  5. 死亡:正常死亡,執行緒執行完畢。非正常死亡,當滿足某個條件後,線上程內部中止執行/在主執行緒中止執行緒物件

然後,由於死鎖問題比較隱蔽,通常很難發現從而去排查,我們只能通過在bugly和內部的crash系統上,分析堆疊資訊:

當發現執行緒大面積的堆疊出現了psynch_cvwait,semwait_signal,psynch_mutexwait,psynch_mutex_trylock,dispatch_sync_f_slow等資訊時,就可以大膽懷疑執行緒非正常原因阻塞,而導致的死鎖。

最後,因為執行緒是一把雙刃劍,不使用執行緒就不會造成死鎖,就需要根據堆疊資訊排查對應的業務:

  1. 鎖用的是否合理
  2. 執行緒的數量是否遠超平時的執行緒數量
  3. 是否使用了NSRecursiveLock,此遞迴鎖不支援多執行緒遞迴,因為會造成優先順序反轉
  4. 排查業務,執行緒長時間的阻塞,導致任務無法正常執行,也會造成死鎖
  5. SCNetworkReachabilityGetFlags,此方法只能在子執行緒呼叫,否則會造成主執行緒同步阻塞
  1. 子執行緒重新整理UI的重點排查與治理

為什麼子執行緒刷UI,只是偶現crash呢?因為在蘋果現有框架下,重新整理UI是一種執行緒不安全的操作,所以必須放在主執行緒。放在子執行緒,恰好競爭同一資源時,才會crash。

所以需要對以下場景,做統一檢查處理

  1. h5互動的回撥
  2. 二方庫,三方庫的代理和回撥
  3. 通知
  4. kvo相關
  5. 介面回撥

因為通知和kvo的觸發和處理都在同一執行緒,如果子執行緒觸發,那麼就有可能子執行緒重新整理UI

  1. 執行緒安全問題的梳理與重構

執行緒安全問題的實質,就是多執行緒寫的問題,嚴謹的說,多執行緒讀並不會造成執行緒安全問題,因為只是讀取資料,並不會產生錯誤的結果,即使交錯執行讀取,最終結果也是正確的。

cpu讀寫記憶體是通過資料匯流排操作的,且只有一個。所以在涉及到多執行緒讀寫問題時,對所有的寫進行序列或者加鎖操作即可,不需要區分資料型別(雖然基本資料型別,多執行緒寫,不會有問題)。

簡單提一下鎖和序列佇列的區別,鎖中間的執行操作相當於是序列佇列。鎖的特點是,鎖定範圍越小越好,但是鎖會造成死鎖。gcd序列佇列,則不會有死鎖的問題。關於用法,仁者見仁。

最後,執行緒安全問題,甚至比死鎖問題還要頑固,頑強。由於祖傳程式碼的原因,不得不對多個業務大類,進行了重構,將資料模型進行了拆分,同時對寫這一塊做了鎖或者序列的操作。

長效機制的建立

執行緒問題比較頭疼,在業務迭代和重構的過程中比較容易出現,如何才能降低執行緒問題對業務和效能的影響呢?

  1. 建立執行緒數量監控預警體系

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:開頭的錯誤日誌彈框提示。

  1. 規範執行緒使用

  2. 業務所有的執行緒同一使用HLLQueuePool來進行排程,同時設定好和業務匹配的優先順序即可,不需要關心排程。

  3. 程式碼review。有執行緒相關的修改或者提交,需要說明,著重review。

覆盤& 總結

本方案於4月份開始落地上線至今,通過資料採集和分析:

  • 涉及到的crash數量大約在26k左右,粗略計算降低了crash率萬分之8
  • 執行緒的平均數量從之前的51.3,降低到現在的41.6,執行緒損耗大約是原來的81%,效能節省了大約18.7%

執行緒治理專項的目的,就是降低crash和效能損耗,從覆盤資料來看,crash修復情況和效能優化均符合預期。

本次主要從佇列的管理和分配,高併發業務的梳理和重構,執行緒使用的合理性評估與改造,執行緒相關crash的排查和修復,長效機制的建立幾個方面介紹了貨拉拉iOS司機端線上程治理方面的實踐

希望我們團隊遇到的問題以及解決的經驗,能夠在穩定性治理方面幫助到你。