20-探究iOS底層原理|多執行緒技術【GCD原始碼分析3:執行緒排程組dispatch_group、事件源dispatch Source】

語言: CN / TW / HK

前言

之前,我們在探索動畫及渲染相關原理的時候,我們輸出了幾篇文章,解答了iOS動畫是如何渲染,特效是如何工作的疑惑。我們深感系統設計者在創作這些系統框架的時候,是如此腦洞大開,也 深深意識到了解一門技術的底層原理對於從事該方面工作的重要性。

因此我們決定 進一步探究iOS底層原理的任務。繼上一篇文章對GCD的 探索iOS底層原理: 柵欄函式dispatch_barrier_asyncdispatch_barrier_sync、訊號量dispatch_semaphore探索之後,本篇文章將繼續對GCD多執行緒底層原理的探索

一、執行緒排程組dispatch_group

1.1 排程組介紹

排程組最直接的作用就是控制任務的執行順序

  • dispatch_group_create :建立排程組
  • dispatch_group_async:進組的任務 執行
  • dispatch_group_notify :進組任務執行完畢的通知
  • dispatch_group_wait: 進組任務執行等待時間
  • dispatch_group_enter :任務進組
  • dispatch_group leave :任務出組

1.2 排程組舉例

下面舉個排程組的應用舉例

給圖片新增水印,有兩張水印照片需要網路請求,水印照片請求,完成之後,再新增到本地圖片上面顯示!

```objc //建立排程組 dispatch_group_t group = dispatch_group_create(); dispatch_queue_t queue = dispatch_get_global_queue(0, 0); // 水印 1 dispatch_group_async(group , queue, ^{ NSString logoStr1 = @"https://thirdqq.qlogo.cn/g?b=sdk&k=zeIp1PmCE6jff6BGSbjicKQ&s=140&t=1556562300"; NSData data1 = [NSData dataWithContentsOfURL:[NSURL URLWithString:logoStr1]]; UIImage image1 = [UIImage imageWithData:data1]; [self.mArray addObject:image1]; }); // 水印 1 dispatch_group_async(group , queue, ^{ NSString logoStr2 = @"https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTJKuHEuLLyYK0Rbw9s9G8jpcnMzQCNsuYJRIRjCvltH6NibibtP73EkxXPR9RaWGHvmHT5n69wpKV2w/132"; NSData data2 = [NSData dataWithContentsOfURL:[NSURL URLWithString:logoStr2]]; UIImage image2 = [UIImage imageWithData:data2]; [self.mArray addObject:image2]; }); // 水印請求完成 dispatch_group_notify(group, dispatch_get_main_queue(), ^{ UIImage newImage = nil; NSLog(@"請求完畢,新增水印"); for (int i = 0; i<self.mArray.count; i++) { UIImage waterImage = self.mArray[i]; newImage =[JP_ImageTool jp_WaterImageWithWaterImage:waterImage backImage:newImage waterImageRect:CGRectMake(20, 100*(i+1), 120, 60)]; } self.imageView.image = newImage;

}); ```

  • 新增水印前

模擬器執行結果—新增水印前

  • 新增水印後

模擬器執行結果—新增水印後 當組內的任務全部執行完成了,dispatch_group_notify會通知,任務已經完成了,內部新增水印的工作可以開始了!

上面的例子還可以使用dispatch_group_enterdispatch_group leave 搭配使用,如下:

進組和出組搭配使用

從上面的兩個例子程式碼可以發現,dispatch_group_async相當於是dispatch_group_enter + dispatch_group leave 的作用!

注意dispatch_group_enterdispatch_group leave 搭配使用,但是順序不能反,否則會奔潰,如下:

奔潰截圖 dispatch_group_enterdispatch_group leave 搭配使用,除了順序不發,個數也得保持一致,人家是出入成雙成對,你不能把它們分開,否則也會罷工或者奔潰的!

  • dispatch_group_enter進組不出組情況

進組不出組情況

dispatch_group_enter進組不出組,那麼dispatch_group_notify就不會收到任務執行完成的通知,dispatch_group_notify內的任務就執行不了

  • 不進組就出組 dispatch_group leave 情況

不進組就出組

不進組就出組,程式會奔潰,都沒有任務進去,你去出去,出個錘子哦!😢

  • dispatch_group_wait等待 舉例

dispatch_group_wait舉例 dispatch_group_wait有點柵欄的感覺,堵住了組裡面前面的任務,但是並沒有阻塞主執行緒。那麼再看看下面這個例子

dispatch_group_wait舉例

  • 這裡使用了dispatch_group_wait進行等待
  • dispatch_group_wait函式會一直等到前面group中的任務執行完,再執行下面程式碼,但會產生阻塞執行緒的問題,導致了主執行緒中的任務5不能正常執行,直到任務組的任務完成才能被呼叫。

思考

  1. 那麼排程組是如何工作,為什麼可以排程任務呢?
  2. dispatch_group_enter進組和dispatch_group_leave出組為什麼能夠起到與排程組dispatch_group_async一樣的效果呢?

現在去看看原始碼尋找答案!

二、排程組原始碼分析

2.1 dispatch_group_create

  • dispatch_group_create

c++ dispatch_group_t dispatch_group_create(void) { return _dispatch_group_create_with_count(0); }

建立排程組會呼叫_dispatch_group_create_with_count方法,並預設傳入0

  • _dispatch_group_create_with_count

c++ static inline dispatch_group_t _dispatch_group_create_with_count(uint32_t n) { dispatch_group_t dg = _dispatch_object_alloc(DISPATCH_VTABLE(group), sizeof(struct dispatch_group_s)); dg->do_next = DISPATCH_OBJECT_LISTLESS; dg->do_targetq = _dispatch_get_default_queue(false); if (n) { os_atomic_store2o(dg, dg_bits, (uint32_t)-n * DISPATCH_GROUP_VALUE_INTERVAL, relaxed); os_atomic_store2o(dg, do_ref_cnt, 1, relaxed); // <rdar://22318411> } return dg; }

_dispatch_group_create_with_count方法裡面通過os_atomic_store2o來把傳入的 n進行儲存,這裡的寫法和訊號量很像(如下圖),是模仿的訊號量的寫法自己寫了一個,但並不是排程組底層是使用訊號量實現的。

dispatch_semaphore_create

2.2 dispatch_group_enter

  • dispatch_group_enter

dispatch_group_enter 通過os_atomic_sub_orig2o會進行0的減減操作,此時的old_bits等於-1

2.3 dispatch_group_leave

  • dispatch_group_leave

dispatch_group_leave

這裡通過os_atomic_add_orig2o-1加加操作,old_state就等於00 & DISPATCH_GROUP_VALUE_MASK的結果依然等於0,也就是old_value等於0DISPATCH_GROUP_VALUE_1的定義如下程式碼: DISPATCH_GROUP_VALUE_1

從程式碼中可以看出old_value是不等於DISPATCH_GROUP_VALUE_MASK的,所以程式碼會執行到外面的if中去,並呼叫_dispatch_group_wake方法進行喚醒,喚醒的就是dispatch_group_notify方法。

也就是說,如果不呼叫dispatch_group_leave方法,也就不會喚醒dispatch_group_notify,下面的流程也就不會執行了。

2.4 dispatch_group_notify

  • dispatch_group_notify

dispatch_group_notifyold_state等於0的情況下,才會去喚醒相關的同步或者非同步函式的執行,也就是block裡面的執行,就是呼叫同步、非同步的那個callout執行。

  • dispatch_group_leave分析中,我們已經得到old_state結果等於0
  • 所以這裡也就解釋了dispatch_group_enterdispatch_group_leave為什麼要配合起來使用的原因,通過訊號量的控制,避免非同步的影響,能夠及時喚醒並呼叫dispatch_group_notify方法
  • dispatch_group_leave裡面也有呼叫_dispatch_group_wake方法,這是因為非同步的執行,任務是執行耗時的,有可能dispatch_group_leave這行程式碼還沒有走,就先走了dispatch_group_notify方法,但這時候dispatch_group_notify方法裡面的任務並不會執行,只是把任務新增到 group
  • 它會等dispatch_group_leave執行了被喚醒才執行,這樣就保證了非同步時,dispatch_group_notify裡面的任務不丟棄,可以正常執行。如下圖所示:

示意圖

  • 當執行任務 2的時候,是耗時任務(sleep(5)模擬耗時),非同步不會堵塞,會執行後面的程式碼,就是圖中①,dispatch_group_notify裡面的任務會包裝起來,進group
  • 包裝完成,非同步執行完,這時候就走 ②了,又回到dispatch_group_leave處去執行了,這時候就可以通過 group 拿到任務 4,直接去呼叫_dispatch_group_wake任務 4喚醒執行了。
  • 這一波是非常的細節,蘋果工程師真是妙啊!

蘋果工程師牛逼

2.5 dispatch_group_async

猜想dispatch_group_async裡面應該是封裝了dispatch_group_enterdispatch_group_leave,所以才能起到一樣的作業效果!

  • dispatch_group_async

dispatch_group_async

dispatch_continuation_t的處理,也就是任務的包裝處理,還做了一些標記處理,最後走_dispatch_continuation_group_async

  • _dispatch_continuation_group_async

_dispatch_continuation_group_async

靚仔!看到沒有,和猜想的是一樣的,內部果然封裝了dispatch_group_enter方法,向組中新增任務時,就呼叫了dispatch_group_enter方法,將訊號量0變成了-1。那麼現在去找下dispatch_group_leave的在哪裡!繼續跟蹤流程。。。

  • _dispatch_continuation_async

_dispatch_continuation_async

這一波又是非常的熟悉了,這個dx_push我們都已經非常熟悉了,非同步、同步的時候經常見這個方法,這裡就不再贅述了(傳送門),會呼叫: - _dispatch_root_queue_push -- > - _dispatch_root_queue_push_inline -- > - _dispatch_root_queue_poke -- > - _dispatch_root_queue_poke_slow -- > - _dispatch_root_queues_init -- > - _dispatch_root_queues_init_once -- > - _dispatch_worker_thread2 -- > _dispatch_root_queue_drain_dispatch_root_queue_drain 然後_dispatch_root_queue_drain -- > _dispatch_continuation_pop_inline -- > _dispatch_continuation_with_group_invoke

_dispatch_continuation_with_group_invoke

在最後_dispatch_continuation_with_group_invoke裡面我們找到了出組的方法dispatch_group_leave 在這裡完成_dispatch_client_callout函式呼叫,緊接著呼叫dispatch_group_leave方法,將訊號量由-1變成了0

至此完成閉環,完整的分析了排程組、進組、出組、通知的底層原理和關係。

三、 Dispatch Source 介紹

3.1 Dispatch Source簡介

Dispatch SourceBSD系統核心慣有功能kqueue的包裝,kqueue是在XNU核心中發生事件時在應用程式程式設計方執行處理的技術。

它的CPU負荷非常小,儘量不佔用資源。當事件發生時,Dispatch Source會在指定的Dispatch Queue中執行事件的處理。

  • dispatch_source_create :建立源
  • dispatch_source_set_event_handler: 設定源的回撥
  • dispatch_source_merge_data: 源事件設定資料
  • dispatch_source_get_data: 獲取源事件的資料
  • dispatch_resume:恢復繼續
  • dispatch_suspend:掛起

我們在日常開發中,經常會使用計時器NSTimer,例如傳送簡訊的倒計時,或者進度條的更新。但是NSTimer需要加入到NSRunloop中,還受到mode的影響。收到其他事件源的影響,被打斷,當滑動scrollView的時候,模式切換,定時器就會停止,從而導致timer的計時不準確。

GCD提供了一個解決方案dispatch_source來出來類似的這種需求場景。

  • 時間較準確,CPU負荷小,佔用資源少
  • 可以使用子執行緒,解決定時器跑在主執行緒上卡UI問題
  • 可以暫停,繼續,不用像NSTimer一樣需要重新建立

3.2 Dispatch Source 使用

建立事件源的程式碼:

```objc // 方法宣告 dispatch_source_t dispatch_source_create( dispatch_source_type_t type, uintptr_t handle, unsigned long mask, dispatch_queue_t _Nullable queue);

// 實現過程 dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_main_queue()); ```

建立的時候,需要傳入兩個重要的引數:

  • dispatch_source_type_t要建立的源型別
  • dispatch_queue_t事件處理的排程佇列

3.3 Dispatch Source 種類

  • Dispatch Source 種類:

  • DISPATCH_SOURCE_TYPE_DATA_ADD 變數增加

  • DISPATCH_SOURCE_TYPE_DATA_OR 變數 OR
  • DISPATCH_SOURCE_TYPE_DATA_REPLACE 新獲得的資料值替換現有的
  • DISPATCH_SOURCE_TYPE_MACH_SEND MACH埠傳送
  • DISPATCH_SOURCE_TYPE_MACH_RECV MACH 埠接收
  • DISPATCH_SOURCE_TYPE_MEMORYPRESSURE記憶體壓力 (注:iOS8後可用)
  • DISPATCH_SOURCE_TYPE_PROC 檢測到與程序相關的事件
  • DISPATCH_SOURCE_TYPE_READ 可讀取檔案映像
  • DISPATCH_SOURCE_TYPE_SIGNAL 接收訊號
  • DISPATCH_SOURCE_TYPE_TIMER 定時器
  • DISPATCH_SOURCE_TYPE_VNODE 檔案系統有變更
  • DISPATCH_SOURCE_TYPE_WRITE 可寫入檔案映像

設計一個定時器舉例: 建立定時器方法

  • 點選螢幕開始

定時器控制方法 使用dispatch_source的計時器,能夠暫停、開始,同時不受主執行緒影響,不會受UI事件的影響,所以它的計時是準確的。如下圖所示:

執行結果

3.4 使用時注意事項

注意事項

  1. source 需要手動啟動

Dispatch Source 使用最多的就是用來實現定時器,source建立後預設是暫停狀態,需要手動呼叫 dispatch_resume啟動定會器。 dispatch_after只是封裝呼叫了dispatch source定時器,然後在回撥函式中執行定義的block.

  1. 迴圈引用

因為 dispatch_source_set_event_handle回撥是block,在新增到source的連結串列上時會執行copy並被source強引用,如果block裡持有了selfself又持有了source的話,就會引起迴圈引用。所以正確的方法是使用weak+strong或者提前調dispatch_source_cancel取消timer

  1. resume、suspend 呼叫次數保持平衡

dispatch_resumedispatch_suspend呼叫次數需要平衡,如果重複呼叫 dispatch_resume則會崩潰,因為重複呼叫會讓dispatch_resume 程式碼裡if分支不成立,從而執行了 DISPATCH_CLIENT_CRASH(“Over-resume of an object”)導致崩潰。

  1. source 建立與釋放時機

sourcesuspend狀態下,如果直接設定 source = nil或者重新建立 source 都會造成crash。正確的方式是在resume狀態下呼叫 dispatch_source_cancel(source)後再重新建立。

四、 Dispatch Source原始碼分析

那麼去底層原始碼看看,為什麼會出現上面的一些問題。

4.1 dispatch_resume

  • dispatch_resume

c++ void dispatch_resume(dispatch_object_t dou) { DISPATCH_OBJECT_TFB(_dispatch_objc_resume, dou); if (unlikely(_dispatch_object_is_global(dou) || _dispatch_object_is_root_or_base_queue(dou))) { return; } if (dx_cluster(dou._do) == _DISPATCH_QUEUE_CLUSTER) { _dispatch_lane_resume(dou._dl, DISPATCH_RESUME); } }

dispatch_resume``會去執行_dispatch_lane_resume

  • _dispatch_lane_resume

_dispatch_lane_resume 這裡的方法是對事件源的相關狀態進行判斷,如果過度resume恢復,則會goto走到over_resume流程,直接調起DISPATCH_CLIENT_CRASH崩潰。

這裡還有對掛起計數的判斷,掛起計數包含所有掛起和非活動位的掛起計數。underflow下溢意味著需要過度恢復或暫停計數轉移到邊計數,也就是說如果當前計數器還沒有到可執行的狀態,需要連續恢復。

4.2 dispatch_suspend

  • 掛起dispatch_suspend

dispatch_suspenddispatch_suspend的定義裡面也可以發現,恢復和掛起一定要保持平衡,掛起的物件不會呼叫與其關聯的任何block。 在與物件關聯的任何執行的 block完成後,物件將被掛起。

c++ void dispatch_suspend(dispatch_object_t dou) { DISPATCH_OBJECT_TFB(_dispatch_objc_suspend, dou); if (unlikely(_dispatch_object_is_global(dou) || _dispatch_object_is_root_or_base_queue(dou))) { return; } if (dx_cluster(dou._do) == _DISPATCH_QUEUE_CLUSTER) { return _dispatch_lane_suspend(dou._dl); } }

  • _dispatch_lane_suspend

_dispatch_lane_suspend

  • _dispatch_lane_suspend_slow

_dispatch_lane_suspend_slow 同樣這裡維護一個暫停掛起的計數器,如果連續呼叫dispatch_suspend掛起方法,減法的無符號下溢可能發生,因為其他執行緒可能在我們嘗試獲取鎖時觸及了該值,或者因為另一個執行緒爭先恐後地執行相同的操作並首先獲得鎖。

所以不能重複的掛起或者恢復,一定要你一個我一個,你兩個我也兩個,保持一個balanced

五、總結

5.1 執行緒排程組

  • 排程組最直接的作用就是控制任務的執行順序
  • dispatch_group_notify :進組任務執行完畢的通知
  • dispatch_group_wait函式會一直等到前面group中的任務執行完,後面的才可以執行
  • dispatch_group_enterdispatch_group leave 成對使用
  • dispatch_group_async內部封裝了dispatch_group_enterdispatch_group leave 的使用

5.2 事件源

  • 使用定時器NSTimer需要加入到NSRunloop,導致計數不準確,可以使用Dispatch Source來解決
  • Dispatch Source的使用,要注意恢復掛起平衡
  • sourcesuspend狀態下,如果直接設定 source = nil或者重新建立 source 都會造成crash。正確的方式是在resume狀態下呼叫 dispatch_source_cancel(source)後再重新建立。
  • 因為 dispatch_source_set_event_handle回撥是block,在新增到source的連結串列上時會執行copy並被source強引用,如果block裡持有了selfself又持有了source的話,就會引起迴圈引用。所以正確的方法是使用weak+strong或者提前調dispatch_source_cancel取消timer
「其他文章」