天天看點

iOS刨根問底-深入了解GCD

iOS刨根問底-深入了解GCD

做過iOS開發的同學相信對于GCD(Grand Central Dispatch)并不陌生,因為在平時多線程開發過程中GCD應該是使用最多的技術甚至它要比它的上層封裝NSOperation還要常用,其中最主要的原因是簡單易用功能強大。本文将從GCD的原理和使用兩個層面分析GCD的内容,本文會結合源碼和執行個體分析使用GCD的注意事項,源碼解讀部分主要通過注釋源碼的方式友善進行源碼分析,具體到細節通過在源碼解釋說明。

iOS刨根問底-深入了解GCD

和前面一篇文章深入了解Runloop一樣GCD的代碼是開源的(也可以直接從蘋果官網下載下傳),這樣要弄清GCD的很多實作原理就有了可能,是以文中不涉及的很多細節大家可以通過源代碼進行了解。下面讓我們看一下關于常見的幾個類型的源碼:

dispatch_queue_t應該是平時接觸最多的一個GCD類型,比如說建立一個隊列,它傳回的就是一個dispatch_queue_t類型:

<code>dispatch_queue_t serialDispatch = dispatch_queue_create("com.cmjstudio.dispatch", nil);</code>

通過檢視源碼可以看到dispatch_queue_t的定義:

上面的源代碼拆分過程盡管繁瑣但是每一步都可以在源碼中順利的找到倒也不是太複雜。最終可以看到 dispatch_queue_t 本身存儲了我們平時常見的label、priority、specific等,本身就是isa指針和引用計數器等一些資訊。

需要說明的是 dispatch 版本衆多,如果檢視目前版本可以直接列印<code>DISPATCH_API_VERSION</code>即可。

dispatch_queue_create 用于建立一個隊列,傳回類型是上面分析過的dispatch_queue_t ,那麼現在看一下如何建立一個隊列:

從源碼注釋也可以看出主要有兩步操作,第一步是 Normalize arguments,第二部才是真正建立隊列,忽略一些參數規範化操作。首先<code>_dispatch_get_root_queue</code>用于擷取root隊列,它有兩個參數:一個是隊列優先級(有6個:userInteractive&gt;default&gt;unspecified&gt;userInitiated&gt;utility&gt;background),另一個是支援不支援過載overcommit(支援overcommit的隊列在建立隊列時無論系統是否有足夠的資源都會重新開一個線程),是以總共就有12個root隊列。對應的源代碼如下(其實是從一個數組中擷取):

至于12個root隊列可以檢視源代碼:

其實我們平時用到的全局隊列也是其中一個root隊列,這個隻要檢視<code>dispatch_get_global_queue</code>代碼就可以了:

可以很清楚的看到,<code>dispatch_get_global_queue</code>的本質就是調用_dispatch_get_root_queue,其中的flag隻是一個蘋果予保留字段,通常我們傳0(你可以試試傳1應該隊列建立失敗),而代入上面的數組當使用<code>dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)</code>。如果列印這個傳回結果可以看到:

首先通過上面數組進行索引<code>2 * (qos - 1) + overcommit</code> = 2*(4-1)+0 = 6 ,可以索引得到 dq_serialnum=10的隊列,剛好label=com.apple.root.default-qos。至于qos參數為什麼是4呢?

然後我們分析一下<code>dispatch_queue_create</code>中的<code>DISPATCH_VTABLE</code>這個宏:

解析之後就是按隊列類型分别擷取不同隊列類型的類: OS_dispatch_queue_concurrent_class 和 OS_dispatch_queue_serial_class ,對比我們平時列印一個隊列的資訊(如下),可以看到 OS_dispatch_queue_serial 或者 OS_dispatch_queue_concurrent_class :

接着看<code>_dispatch_object_alloc</code>和<code>_dispatch_queue_init</code>,分别用于申請對應類型的記憶體和初始化。首先看前者的實作:

然後看一下記憶體配置設定之後的初始化<code>_dispatch_queue_init</code>源碼,也隻是簡單的進行了初始化工作,不過值得一提的是<code>dqai.dqai_concurrent ? DISPATCH_QUEUE_WIDTH_MAX : 1</code>這個參數,<code>DISPATCH_QUEUE_WIDTH_MAX</code>其實看一下源碼就知道是0x1000ull-2就是0xffe,而如果是串行隊列就是1,這也是為什麼可以在上面列印中看到<code>width = 0x1</code>的原因,width本身就是并發數的個數,對于串行隊列是1而對于并發隊列是不限制的(回過頭去看全局隊列width為什麼是0xfff呢,因為它的width是#define DISPATCH_QUEUE_WIDTH_POOL (DISPATCH_QUEUE_WIDTH_FULL - 1)

)=0x1000ull-1:

接着看<code>dispatch_queue_create</code>的<code>dq-&gt;do_targetq = tq;</code>這句話是什麼意思呢?這個其實是當使用<code>dispatch_queue_create</code>建立的自定義隊列(事實上包括主隊列和管理隊列,也就是非全局隊列[可以看一下上面的源代碼全局隊列并沒有設定do_targetq,但是事實上它本身就是root隊列]),都需要壓入到全局隊列(這裡指的是root隊列)進行處理,這個目标隊列的目的就是允許我們将一個隊列放在另一個隊列裡執行任務。看一下上面建立自定義隊列的源碼不難發現,如果是自定義一個串行隊列其實最終就是一個root隊列。

為了驗證上面關于主隊列也是root隊列的說法不放看一下主隊列的源碼:

可以看到主隊列do_targetq也是一個root隊列(通過擷取_dispatch_root_queues),DISPATCH_ROOT_QUEUE_IDX_DEFAULT_QOS =6 是以 <code>_dispatch_root_queues[6+1]</code>就是<code>com.apple.root.default-qos.overcommit</code>,不妨列印一些主隊列(如下),可以看到target正是<code>com.apple.root.default-qos.overcommit</code>,而且width=1,其次由于<code>dispatch_queue_main_t</code>是對dispatch_queue_serial的重寫是以也是一個串行隊列:

到了這裡關于隊列的建立我們已經基本介紹完了,可以看到不管是自定義隊列、全局隊列還是主隊列最終都直接或者間接的依賴12個root隊列來執行任務排程(盡管如此主隊列有自己的label,如果按照label計算總共16個,除了上面的12個,就是<code>com.apple.main-thread</code>還有兩個内部管理隊列<code>com.apple.libdispatch-manager</code>和<code>com.apple.root.libdispatch-manager</code>以及runloop的運作隊列)。下面看一下幾個常用的隊列任務的執行方法的源碼,對于任務的執行GCD其實主要用兩個方法<code>dispatch_sync</code>和<code>dispatch_async</code>。

上面提到一個重要概念是overcommit,overcommit的隊列在隊列建立時會建立一個線程,非overcommit隊列建立隊列則未必建立線程。另外width=1意味着是串行隊列,隻有一個線程可用,width=0xffe則意味着并行隊列,線程則是從線程池擷取,可用線程數是64個。

可以看到全局隊列是非overcommit的(flat保留字隻能傳0,如果預設優先級則是com.apple.root.default-qos,但是width=0xffe是并行隊列);主隊列是overcommit的com.apple.root.default-qos.overcommit,不過它是串行隊列,width=1,并且運作的這個線程隻能是主線程;自定義串行隊列是overcommit的,預設優先級則是 com.apple.root.default-qos.overcommit,并行隊列則是非overcommit的。

這裡看一下為什麼上面說并行隊列最大線程數是64個,不妨結合幾個例子來檢視:

可以看到對于 dispatch_asyn 的調用(同步操作線程都在主線程不再贅述)串行隊列是overcommit的,建立隊列會建立1個新的線程,并行隊列是非overcommit的,不一定會建立線程,會從線程池中的64個線程中擷取并使用。另外上面的dispatch_set_target_queue 操作和前面源碼中的do_targetq是作用一樣的。

這樣以來反而串行隊列是開發中應該注意的,因為一旦建立一個串行隊列就會建立一個線程,避免在類似循環操作中建立串行隊列,這個上限是多少是任意多嗎?其實也不是最多新增512個(不算主線程,number從4開始到515)但是這明顯已經是災難性的了。另外對于多個同一優先級的自定義串行隊列(比如:com.apple.root.default-qos.overcommit)對于 dispatch_asyn 調用又怎麼保證調用順序呢?盡管是overcommit可以建立多個線程,畢竟都在一個root隊列中執行,優先級又是相同的。

先看一段代碼:

三次執行順序依次如下:

确實單次執行都建立了新的線程(和前面說的 overcommit 是相符的),但是執行任務的順序可以說是随機的,這個和線程排程有關,那麼如果有比較重的任務會不會造成影響呢?這個答案是如果都分别建立了隊列(overcommit)一般不會有影響,除非建立超過了512個,因為盡管是同一個root隊列但是會建立不同的線程,此時目前root隊列僅僅控制任務FIFO,但是并不是隻有第一個任務執行完第二個任務才能開始,也就是說FIFO控制的是開始的節奏,但是任務在不同的thread執行不會阻塞。當然一個串行隊列中的多個異步task是互相有執行順序的,比如下面的代碼task2一定會被task1阻塞,但是都不會阻塞task3:

可以看到首先通過width判定是串行隊列還是并發隊列,如果是并發隊列則調用<code>_dispatch_sync_invoke_and_complete</code>,串行隊列則調用<code>_dispatch_barrier_sync_f</code>。先展開看一下串行隊列的同步執行源代碼:

首先擷取線程id,然後處理死鎖的情況,是以這裡先看一下死鎖的情況:

隊列push以後就是用<code>_dispatch_lock_is_locked_by</code>判斷将要排程的和目前等待的隊列是不是同一個,如果相同則傳回YES,産生死鎖<code>DISPATCH_CLIENT_CRASH</code>;如果沒有産生死鎖,則執行 _dispatch_trace_item_pop()出隊列執行。如何執行排程呢,需要看一下<code>_dispatch_sync_invoke_and_complete_recurse</code>?

可以比較清楚的看到最終執行f函數,這個就是外界傳過來的回調block。

可以看到<code>dx_push</code>已經到了<code>_dispatch_root_queue_push</code>,這是可以接着檢視<code>_dispatch_root_queue_push</code>:

到了這裡可以清楚的看到對于全局隊列使用<code>_pthread_workqueue_addthreads</code>開辟線程,對于其他隊列使用<code>pthread_create</code>開辟新的線程。那麼任務執行的代碼為什麼沒看到?其實_dispatch_root_queues_init中會首先執行第一個任務:

另外對于<code>_dispatch_continuation_init</code>的代碼中的并沒有對其進行展開,其實_dispatch_continuation_init中的<code>func</code>就是<code>_dispatch_call_block_and_release</code>(源碼如下),它在<code>dx_push</code>調用時包裝進了<code>qos</code>。

dispatch_async代碼實作看起來比較複雜,因為其中的資料結構較多,分支流程控制比較複雜。不過思路其實很簡單,用連結清單儲存所有送出的 block(先進先出,,在隊列本身維護了一個連結清單新加入block放到連結清單尾部),然後在底層線程池中,依次取出 block 并執行。

類似的可以看到<code>dispatch_barrier_async</code>源碼和dispatch_async幾乎一緻,僅僅多了一個标記位<code>DC_FLAG_BARRIER</code>,這個标記位用于在取出任務時進行判斷,正常的異步調用會依次取出,而如果遇到了<code>DC_FLAG_BARRIER</code>則會傳回,是以可以等待所有任務執行結束執行dx_push(不過提醒一下dispatch_barrier_async必須在自定義隊列才有用,原因是global隊列沒有v_table結構,同時不要試圖在主隊列調用,否則會crash):

下面的代碼在objc開發中應該很常見,這種方式可以保證instance隻會建立一次:

不放分析一下dispatch_once的源碼:

說到這裡,從swift3.0以後已經沒辦法使用dispach_once了,其實原因很簡單因為在swift1.x的<code>static var/let</code>屬性就已經是<code>dispatch_once</code>在背景執行的了,是以對于單例的建立沒有必要顯示調用了。但是有時候其他情況我們還是需要使用單次執行怎麼辦呢?代替方法:使用全局變量(例如建立一個對象執行個體或者初始化成一個立即執行的閉包:let g = {}();_ = g;),當然習慣于dispatch_once的朋友有時候并不适應這種方法,這裡給出一個比較簡單的方案:

dispatch_after也是一個常用的延遲執行的方法,比如常見的使用方法是:

在檢視<code>dispatch_after</code>源碼之前先看一下另一個内容事件源<code>dispatch_source_t</code>,其實<code>dispatch_source_t</code>是一個很少讓開發者和GCD聯想到一起的一個類型,它本身也有對應的建立方法<code>dispatch_source_create</code>(事實上它的使用甚至可以追蹤到Runloop)。多數開發者認識<code>dispatch_source_t</code>都是通過定時器,很多文章會教你如何建立一個比較準确的定時器,比如下面的代碼:

如果你知道上面一個定時器如何執行的那麼下面看一下dispatch_after應該就比較容易明白了:

代碼并不是太複雜,無時間差則直接調用<code>dispatch_async</code>,否則先建立一個<code>dispatch_source_t</code>,不同的是這裡的類型并不是<code>DISPATCH_SOURCE_TYPE_TIMER</code>而是<code>_dispatch_source_type_after</code>,檢視源碼不難發現它隻是dispatch_source_type_s類型的一個常量和<code>_dispatch_source_type_timer</code>并沒有明顯差別:

而和dispatch_activate()其實和dispatch_resume() 是一樣的開啟定時器。那麼為什麼看不到<code>dispatch_source_set_event_handler</code>來給timer設定handler呢?不放看一下<code>dispatch_source_set_event_handler</code>的源代碼:

可以看到最終還是封裝成一個<code>dispatch_continuation_t</code>進行同步或者異步調用,而上面<code>_dispatch_after</code>直接建構了<code>dispatch_continuation_t</code>進行執行。

使用<code>dispatch_after</code>還有一個問題就是取消問題,當然通常遇到了這種問題大部分答案就是使用下面的方式:

不過如果你使用的是iOS 8及其以上的版本,那麼其實是可以取消的(如下),當然如果你還在支援iOS 8以下的版本不妨試試這個自定義的dispatch_cancelable_block_t類:

如果你用的是swift那麼恭喜你,很簡單:

信号量是線程同步操作中很常用的一個操作,常用的幾個類型:

dispatch_semaphore_t:信号量類型

dispatch_semaphore_create:建立一個信号量

dispatch_semaphore_wait:發送一個等待信号,信号量-1,當信号量為0阻塞線程,大于0則開始執行後面的邏輯(也就是說執行dispatch_semaphore_wait前如果信号量&lt;=0則阻塞,否則正常執行後面的邏輯)

dispatch_semaphore_signal:發送喚醒信号,信号量會+1

比如我們有個操作foo()在異步線程已經開始執行,同時可能使用者會手動再次觸發動作bar(),但是bar依賴foo完成則可以使用信号量:

那麼信号量是如何實作的呢,不妨看一下它的源碼:

信号量是一個比較重要的内容,合理使用可以讓你的程式更加的優雅,比如說一個常見的情況:大家知道<code>PHImageManager.requestImage</code>是一個釋放消耗記憶體的方法,有時我們需要批量擷取到圖檔執行一些操作的話可能就沒辦法直接for循環,不然記憶體會很快爆掉,因為每個requestImage操作都需要占用大量記憶體,即使外部嵌套autoreleasepool也不一定可以及時釋放(想想for執行的速度,釋放肯定來不及),那麼requestImage又是一個異步操作,如此隻能讓一個操作執行完再執行另一個循環操作才能解決。也就是說這個問題就變成for循環内部的異步操作串行執行的問題。要解決這個問題有幾種思路:1.使用requestImage的同步請求照片 2.使用遞歸操作一個操作執行完再執行另外一個操作移除for操作 3.使用信号量解決。當然第一個方法并非普适,有些異步操作并不能輕易改成同步操作,第二個方法相對普适,但是遞歸調用本身因為要改變原來的代碼結構看起來不是那麼優雅,自然目前讨論的信号量是更好的方式。我們假設requestImage是一個bar(callback:((_ image)-&gt; Void))操作,整個請求是一個foo(callback:((_ images)-&gt;Void))那麼它的實作方式如下:

可以看到信号量在做線程同步時簡單易用,不過有時候不經意間容易出錯,比如下面的代碼會出現<code>EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)</code>錯誤,原因是之前的信号量還在使用:

為什麼會這樣呢?原因和上面<code>dispatch_semaphore_create</code>中的<code>DISPATCH_VTABLE(semaphore)</code>有關系,這個宏我們上面分析過,最終展開就是<code>OS_dispatch_semaphore_class</code>執行個體的引用,那麼它的執行個體是什麼呢?它當然是通過<code>_dispatch_object_alloc</code>建立的,沿着查找<code>_dispatch_object_alloc</code>的源碼可以找到下面的代碼:

不難看出就是依靠<code>class_createInstance</code>建立一個<code>OS_dispatch_semaphore_class</code>執行個體,這個代碼在libdispatch是找不到的,它在runtime源碼中。不過在這裡可以找到它的執行個體的定義(其實類似的通過vtable結建構立的執行個體都包含在libdispatch的init.c中):

不難看出這個對象是包含一個dispose方法的,就是<code>_dispatch_semaphore_dispose</code>,我們可以看到它的源碼,其實這裡對我們排查問題最重要的就是if條件語句,信号量的目前值小于初始化,會發生閃退,因為信号量已經被釋放了,如果此時沒有crash其實就會意味着一直有線程在信号量等待:

<code>dispatch_group</code>常常用來同步多個任務(注意和<code>dispatch_barrier_sync</code>不同的是它可以是多個隊列的同步),是以其實上面先分析<code>dispatch_semaphore</code>也是這個原因,它本身是依靠信号量來完成的同步管理。典型的用法如下:

下面看一下<code>dispatch_group</code>相關的源碼:

簡單的說就是<code>dispatch_group_async</code>和<code>dispatch_group_notify</code>本身就是和<code>dispatch_group_enter</code>、<code>dispatch_group_leave</code>沒有本質差別,後者相對更加靈活。當然這裡還有一個重要的操作就是<code>dispatch_group_wait</code>,還沒有看:

上面第一個<code>dispatch_group</code>例子介紹的情況很簡單,任務本身都是同步的,隻是将一個同步任務放到了<code>dispatch_group_async</code>中,現實中這個操作可能是一個網絡請求,你現在想讓10個請求都完成後再執行某個操作怎麼辦(網絡請求假設方法是request(url:String,complete:Callback))?你現在不可能在網絡請求方法内部做出修改了,怎麼保證操作同步呢?

之前看到過這種操作:

其實這種方法基本沒有用<code>dispatch_group</code>,直接用信号量就可以解決,有了上面的分析使用<code>dispatch_enter</code>和<code>dispatch_leave</code>就可以了。

dispatch_apply設計的主要目的是提高并行能力(注意不是并發,等同于Swift中的DispatchQueue.concurrentPerform),是以一般我們用來并行執行多個結構類似的任務,比如:

在GCD中其實總共有兩個線程池進行線程管理,一個是主線程池,另一個是除了主線程池之外的線程池。主線程池由序列為1的主隊列管理,使用objc.io上的一幅圖表示如下:

iOS刨根問底-深入了解GCD

大家都知道使用dispatch_sync很有可能會發生死鎖那麼這是為什麼呢?

不妨回顧一下dispatch_sync的過程:

重點在<code>_dq_state_drain_locked_by(dq_state, dsc-&gt;dsc_waiter)</code>這個條件,成立則會發生死鎖,那麼它成立的條件就是<code>((lock_value ^ tid) &amp; DLOCK_OWNER_MASK) == 0</code>首先lock_value和tid進行異或操作,相同為0不同為1,然後和DLOCK_OWNER_MASK(0xfffffffc)進行按位與操作,一個為0則是0,是以若幹lock_value和tid相同則會發生死鎖。

__builtin_expect是一個針對編譯器優化的内置函數,讓編譯更加優化。比如說我們會寫這種代碼:

如果我們更加傾向于使用a那麼可将其設為預設值,極特殊情況下才會使用b條件。CPU讀取指定是多條一起加載的,可能先加載進來的是a,那麼如果遇到執行b的情況則再加載b,那麼對于條件a的情況就造成了性能浪費。long __builtin_expect (long EXP, long C) 第一個參數是要預測變量,第二個參數是預測值,這樣__builtin_expect(a,false)說明多數情況a應該是false,極少數情況可能是true,這樣不至于造成性能浪費。其實對于編譯器在彙編時會優化成<code>if !a</code>的形式:

看了likely和unlikely可以了解,likely表示更大可能成立,unlikely表示更大可能不成立。likely就是 if(likely(x == 0)) 就是if (x==0)。

第二個參數與第一個參數值比較,如果相等,第三個參數的值替換第一個參數的值。如果不相等,把第一個參數的值指派到第二個參數上。

将第二個參數儲存到第一個參數中

第一個參數指派為1

第二個參數加1并傳回

第二個參數-1并傳回

dispatch_barrier_async

dispatch_apply()

iOS刨根問底-深入了解GCD

本作品采用知識共享署名 2.5 中國大陸許可協定進行許可,歡迎轉載,演繹或用于商業目的。但轉載請注明來自崔江濤(KenshinCui),并包含相關連結。