iOS多執行緒程式設計——GCD與NSOperation總結

發表於2016-03-07

很長時間以來,我個人(可能還有很多同學),對多執行緒程式設計都存在一些誤解。一個很明顯的表現是,很多人有這樣的看法: 

新開一個執行緒,能提高速度,避免阻塞主執行緒

畢竟多執行緒嘛,幾個執行緒一起跑任務,速度快,還不阻塞主執行緒,簡直完美。

在某些場合,我們還見過另一個“高深”的名詞——“非同步”。這東西好像和多執行緒挺類似,經過一番百度(閱讀了很多質量層次不齊的文章)之後,很多人也沒能真正搞懂何為“非同步”。

於是,帶著對“多執行緒”和“非同步”的懵懂,很多人又開開心心踏上了多執行緒程式設計之旅,比如文章待會兒會提到的GCD。

何為多執行緒

其實,如果不考慮其他任何因素和技術,多執行緒有百害而無一利,只能浪費時間,降低程式效率。

是的,我很清醒的寫下這句話。

試想一下,一個任務由十個子任務組成。現在有兩種方式完成這個任務:
1. 建十個執行緒,把每個子任務放在對應的執行緒中執行。執行完一個執行緒中的任務就切換到另一個執行緒。
2. 把十個任務放在一個執行緒裡,按順序執行。

作業系統的基礎知識告訴我們,執行緒,是執行程式最基本的單元,它有自己棧和暫存器。說得再具體一些,執行緒就是“一個CPU執行的一條無分叉的命令列”

對於第一種方法,在十個執行緒之間來回切換,就意味著有十組棧和暫存器中的值需要不斷地被備份、替換。
而對於對於第二種方法,只有一組暫存器和棧存在,顯然效率完勝前者。

併發與並行

通過剛剛的分析我們看到,多執行緒本身會帶來效率上的損失。準確來說,在處理併發任務時,多執行緒不僅不能提高效率,反而還會降低程式效率。

所謂的“併發”,英文翻譯是concurrent。要注意和“並行(parallelism)”的區別。

併發指的是一種現象,一種經常出現,無可避免的現象。它描述的是“多個任務同時發生,需要被處理”這一現象。它的側重點在於“發生”。

比如有很多人排隊等待檢票,這一現象就可以理解為併發。

並行指的是一種技術,一個同時處理多個任務的技術。它描述了一種能夠同時處理多個任務的能力,側重點在於“執行”。

比如景點開放了多個檢票視窗,同一時間內能服務多個遊客。這種情況可以理解為並行。

並行的反義詞就是序列,表示任務必須按順序來,一個一個執行,前一個執行完了才能執行後一個。

我們經常掛在嘴邊的“多執行緒”,正是採用了並行技術,從而提高了執行效率。因為有多個執行緒,所以計算機的多個CPU可以同時工作,同時處理不同執行緒內的指令。

併發是一種現象,面對這一現象,我們首先建立多個執行緒,真正加快程式執行速度的,是並行技術。也就是讓多個CPU同時工作。而多執行緒,是為了讓多個CPU同時工作成為可能。

同步與非同步

同步方法就是我們平時呼叫的哪些方法。因為任何有程式設計經驗的人都知道,比如在第一行呼叫foo()方法,那麼程式執行到第二行的時候,foo方法肯定是執行完了。

所謂的非同步,就是允許在執行某一個任務時,函式立刻返回,但是真正要執行的任務稍後完成。

比如我們在點選儲存按鈕之後,要先把資料寫到磁碟,然後更新UI。同步方法就是等到資料儲存完再更新UI,而非同步則是立刻從儲存資料的方法返回並向後執行程式碼,同時真正用來儲存資料的指令將在稍後執行。

區別和聯絡

假設現在有三個任務需要處理。假設單個CPU處理它們分別需要3、1、1秒。

並行與序列,其實討論的是處理這三個任務的速度問題。如果三個CPU並行處理,那麼一共只需要3秒。相比於序列處理,節約了兩秒。

而同步/非同步,其實描述的是任務之間先後順序問題。假設需要三秒的那個是儲存資料的任務,而另外兩個是UI相關的任務。那麼通過非同步執行第一個任務,我們省去了三秒鐘的卡頓時間。

對於同步執行的三個任務來說,系統傾向於在同一個執行緒裡執行它們。因為即使開了三個執行緒,也得等他們分別在各自的執行緒中完成。並不能減少總的處理時間,反而徒增了執行緒切換(這就是文章開頭舉的例子)

對於非同步執行的三個任務來說,系統傾向於在三個新的執行緒裡執行他們。因為這樣可以最大程度的利用CPU效能,提升程式執行效率。

總結

於是我們可以得出結論,在需要同時處理IO和UI的情況下,真正起作用的是非同步,而不是多執行緒。可以不用多執行緒(因為處理UI非常快),但不能不用非同步(否則的話至少要等IO結束)。

注意到我把“傾向於”這三個加粗了,也就是說非同步方法並不一定永遠在新執行緒裡面執行,反之亦然。在接下來關於GCD的部分會對此做出解釋。

GCD簡介

GCD以block為基本單位,一個block中的程式碼可以為一個任務。下文中提到任務,可以理解為執行某個block

同時,GCD中有兩大最重要的概念,分別是“佇列”和“執行方式”。

使用block的過程,概括來說就是把block放進合適的佇列,並選擇合適的執行方式去執行block的過程。

三種佇列:

  1. 序列佇列(先進入佇列的任務先出佇列,每次只執行一個任務)
  2. 並行佇列(依然是“先入先出”,不過可以形成多個任務併發)
  3. 主佇列(這是一個特殊的序列佇列,而且佇列中的任務一定會在主執行緒中執行)

兩種執行方式:

  1. 同步執行
  2. 非同步執行

關於同步非同步、序列並行和執行緒的關係,下面通過一個表格來總結

可以看到,同步方法不一定在本執行緒,非同步方法方法也不一定新開執行緒(考慮主佇列)。

然而事實上,在本文一開始就揭開了“多執行緒”的神祕面紗,所以我們在程式設計時,更應該考慮的是:

同步 Or 非同步

以及

序列 Or 並行

而非僅僅考慮是否新開執行緒。

當然,瞭解任務執行在那個執行緒中也是為了更加深入的理解整個程式的執行情況,尤其是接下來要討論的死鎖問題。

GCD的死鎖問題

在使用GCD的過程中,如果向當前序列佇列中同步派發一個任務,就會導致死鎖。

這句話有點繞,我們首先舉個例子看看:

這段程式碼就會導致死鎖,因為我們目前在主佇列中,又將要同步地新增一個block到主佇列(序列)中。

理論分析

我們知道dispatch_sync表示同步的執行任務,也就是說執行dispatch_sync後,當前佇列會阻塞。而dispatch_sync中的block如果要在當前佇列中執行,就得等待當前佇列程執行完成。

在上面這個例子中,主佇列在執行dispatch_sync,隨後佇列中新增一個任務block。因為主佇列是同步佇列,所以block要等dispatch_sync執行完才能執行,但是dispatch_sync是同步派發,要等block執行完才算是結束。在主佇列中的兩個任務互相等待,導致了死鎖。

解決方案

其實在通常情況下我們不必要用dispatch_sync,因為dispatch_async能夠更好的利用CPU,提升程式執行速度。

只有當我們需要保證佇列中的任務必須順序執行時,才考慮使用dispatch_sync。在使用dispatch_sync的時候應該分析當前處於哪個佇列,以及任務會提交到哪個佇列。

GCD任務組

瞭解完佇列之後,很自然的會有一個想法:我們怎麼知道所有任務都已經執行完了呢?

在單個序列佇列中,這個不是問題,因為只要把回撥block新增到佇列末尾即可。

但是對於並行佇列,以及多個序列、並行佇列混合的情況,就需要使用dispatch_group了。

首先我們要通過dispatch_group_create()方法生成一個組。

接下來,我們把dispatch_async方法換成dispatch_group_async。這個方法多了一個引數,第一個引數填剛剛建立的分組。

想問dispatch_sync對應的分組方法是什麼的童鞋面壁思過三秒鐘,思考一下group出現的目的和dispatch_sync的特點。

最後呼叫dispatch_group_create方法。這個方法表示把第三個引數block傳入第二個引數佇列中去。而且可以保證第三個引數block執行時,group中的所有任務已經全部完成。

dispatch_group

dispatch_group_wait方法是一個很有用的方法,它的完整定義如下:

dispatch_group_wait(group: dispatch_group_t, _ timeout: dispatch_time_t) -> Int

第一個參數列示要等待的group,第二個則表示等待時間。返回值表示經過指定的等待時間,屬於這個group的任務是否已經全部執行完,如果是則返回0,否則返回非0。

第二個dispatch_time_t型別的引數還有兩個特殊值:DISPATCH_TIME_NOWDISPATCH_TIME_FOREVER

前者表示立刻檢查屬於這個group的任務是否已經完成,後者則表示一直等到屬於這個group的任務全部完成。

dispatch_after方法

通過GCD還可以進行簡單的定時操作,比如在1秒後執行某個block。程式碼如下:

dispatch_after方法有三個引數。第一個表示時間,也就是從現在起往後三秒鐘。第二三個引數分別表示要提交的任務和提交到哪個佇列。

需要注意的是和dispatch_after僅表示在指定時間後提交任務,而非執行任務。如果任務提交到主佇列,它將在main runloop中執行,對於每隔1/60秒執行一次的RunLoop,任務最多有可能在3+1/60秒後執行。

NSOperation

NSOperationNSOperationQueue主要涉及這幾個方面:

  1. NSOperation和和NSOperationQueue用法介紹
  2. NSOperation的暫停、恢復和取消
  3. 通過KVO對NSOperation的狀態進行檢測
  4. 多個NSOperation的之間的依賴關係

從簡單意義上來說,NSOperation是對GCD中的block進行的封裝,它也表示一個要被執行的任務。

與GCD中的block類似,NSOperation物件有一個start()方法表示開始執行這個任務。

不僅如此,NSOperation表示的任務還可以被取消。它還有三種狀態isExecutedisFinishedisCancelled以方便我們通過KVC對它的狀態進行監聽。

想要開始執行一個任務可以這麼寫:

以上程式碼會得到這樣的執行結果:

首先我們建立了一個NSBlockOperation,並且設定好它的block,也就是將要執行的任務。這個任務會在主執行緒中執行。

NSBlockOperation是因為NSOperation是一個基類,不應該直接生成NSOperation物件,而是應該用它的子類。NSBlockOperation是蘋果預定義的子類,它可以用來封裝一個或多個block,後面會介紹如何自己建立NSOperation的子類。

同時,還可以呼叫addExecutionBlock方法追加幾個任務,這些任務會並行執行(也就是說很有可能執行在別的執行緒裡)。

最後,呼叫start方法讓NSOperation方法執行起來。start是一個同步方法。

NSOperationQueue

剛剛我們知道,預設的NSOperation是同步執行的。簡單的看一下NSOperation類的定義會發現它有一個只讀屬性asynchronous

這意味著如果想要非同步執行,就需要自定義NSOperation的子類。或者使用NSOperationQueue

NSOperationQueue類似於GCD中的佇列。我們知道GCD中的佇列有三種:主佇列序列佇列並行佇列NSOperationQueue更簡單,只有兩種:主佇列非主佇列

我們自己生成的NSOperationQueue物件都是非主佇列,主佇列可以用NSOperationQueue.mainQueue取得。

NSOperationQueue的主佇列是序列佇列,而且其中所有NSOperation都會在主執行緒中執行。

對於非主佇列來說,一旦一個NSOperation被放入其中,那這個NSOperation一定是併發執行的。因為NSOperationQueue會為每一個NSOperation建立執行緒並呼叫它的start方法。

NSOperationQueue有一個屬性叫maxConcurrentOperationCount,它表示最多支援多少個NSOperation併發執行。如果maxConcurrentOperationCount被設為1,就以為這個佇列是序列佇列。

因此,NSOperationQueue和GCD中的佇列有這樣的對應關係:

回到開頭的問題,如何利用NSOperationQueue實現非同步操作呢,程式碼如下:

得到執行結果如下:

使用NSOperationQueue來執行任務與之前的區別在於,首先建立一個非主佇列。然後用addOperation方法替換之前的start方法。剛剛已經說過,NSOperationQueue會為每一個NSOperation建立執行緒並呼叫他們的start方法。

觀察一下執行結果,所有的NSOperation都沒有在主執行緒執行,從而成功的實現了非同步、並行處理。

NSOperation新特性

在學習NSOperation的時候,我們總是用GCD的概念去解釋。但是NSOperation作為對GCD更高層次的封裝,它有著一些GCD無法實現(或者至少說很難實現)的特性。由於NSOperationNSOperationQueue良好的封裝,這些新特性的使用都非常簡單。

取消任務

如果我們有兩次網路請求,第二次請求會用到第一次的資料。如果此時網路情況不好,第一次請求超時了,那麼第二次請求也沒有必要傳送了。當然,使用者也有可能人為地取消某個NSOperation

當某個NSOperation被取消時,我們應該儘可能的清除NSOperation內部的資料並且把cancelledfinished設為true,把executing設為false

設定依賴

依然考慮剛剛所說的兩次網路請求的例子。因為第二次請求會用到第一次的資料,所以我們要保證發出第二次請求的時候第一個請求已經執行完。但是我們同時還希望利用到NSOperationQueue的併發特性(因為可能不止這兩個任務)。

這時候我們可以設定NSOperation之間的依賴關係。語法非常簡潔:

需要注意的是NSOperation之間的相互依賴會導致死鎖

NSOperationQueue暫停與恢復

這個更加簡單,只要修改suspended屬性即可

NSOperation優先順序

GCD中,任務(block)是沒有優先順序的,而佇列具有優先順序。和GCD相反,我們一般考慮NSOperation的優先順序

NSOperation有一個NSOperationQueuePriority列舉型別的屬性queuePriority

需要注意的是,NSOperationQueue也不能完全保證優先順序高的任務一定先執行。

NSOperation和GCD如何選擇

其實經過這兩篇文章的分析,我們大概對NSOperationGCD都有了比較詳細的瞭解,同時在親自運用這兩者的過程中有了自己的理解。

GCD以block為單位,程式碼簡潔。同時GCD中的佇列、組、訊號量、source、barriers都是組成並行程式設計的基本原語。對於一次性的計算,或是僅僅為了加快現有方法的執行速度,選擇輕量化的GCD就更加方便。

NSOperation可以用來規劃一組任務之間的依賴關係,設定它們的優先順序,任務能被取消。佇列可以暫停、恢復。NSOperation還可以被子類化。這些都是GCD所不具備的。

所以我們要記住的是:

NSOperation和GCD並不是互斥的,有效地結合兩者可以開發出更棒的應用

GCD進階

NSOperation有自己獨特的優勢,GCD也有一些強大的特性。接下來我們由淺入深,討論以下幾個部分:

  • dispatch_suspenddispatch_resume
  • dispathc_once
  • dispatch_barrier_async
  • dispatch_semaphore

dispatch_suspend和dispatch_resume

我們知道NSOperationQueue有暫停(suspend)和恢復(resume)。其實GCD中的佇列也有類似的功能。用法也非常簡單:

這些函式不會影響到佇列中已經執行的任務,佇列暫停後,已經新增到佇列中但還沒有執行的任務不會執行,直到佇列被恢復。

dispathc_once

首先我們來看一下最簡單的dispathc_once函式,這在單例模式中被廣泛使用。

  • dispathc_once函式可以確保某個block在應用程式執行的過程中只被處理一次,而且它是執行緒安全的。所以單例模式可以很簡單的實現,以OC中Manager類為例

這段程式碼中我們建立一個值為nil的sharedManagerInstance靜態物件,然後把它的初始化程式碼放到dispatch_once中完成。

這樣,只有第一次呼叫sharedInstance方法時才會進行物件的初始化,以後每次只是返回sharedManagerInstance而已。

dispatch_barrier_async

我們知道資料在寫入時,不能在其他執行緒讀取或寫入。但是多個執行緒同時讀取資料是沒有問題的。所以我們可以把讀取任務放入並行佇列,把寫入任務放入序列佇列,並且保證寫入任務執行過程中沒有讀取任務可以執行。

這樣的需求比較常見,GCD提供了一個非常簡單的解決辦法——dispatch_barrier_async

假設我們有四個讀取任務,在第二三個任務之間有一個寫入任務,程式碼大概是這樣:

如果程式碼這樣寫,由於這幾個block是併發執行,就有可能在前兩個block中讀取到已經修改了的資料。如果是有多寫入任務,那問題更嚴重,可能會有資料競爭。

如果使用dispatch_barrier_async函式,程式碼就可以這麼寫:

dispatch_barrier_async會把並行佇列的執行週期分為這三個過程:

  1. 首先等目前追加到並行佇列中所有任務都執行完成
  2. 開始執行dispatch_barrier_async中的任務,這時候即使向並行佇列提交任務,也不會執行
  3. dispatch_barrier_async中的任務執行完成後,並行佇列恢復正常。

總的來說,dispatch_barrier_async起到了“承上啟下”的作用。它保證此前的任務都先於自己執行,此後的任務也遲於自己執行。正如barrier的含義一樣,它起到了一個柵欄、或是分水嶺的作用。

這樣一來,使用並行佇列和dispatc_barrier_async方法,就可以高效的進行資料和檔案讀寫了。

dispatch_semaphore

首先介紹一下訊號量(semaphore)的概念。訊號量是持有計數的訊號,不過這麼解釋等於沒解釋。我們舉個生活中的例子來看看。

假設有一個房子,它對應程式的概念,房子裡的人就對應著執行緒。一個程式可以包括多個執行緒。這個房子(程式)有很多資源,比如花園、客廳等,是所有人(執行緒)共享的。

但是有些地方,比如臥室,最多隻有兩個人能進去睡覺。怎麼辦呢,在臥室門口掛上兩把鑰匙。進去的人(執行緒)拿著鑰匙進去,沒有鑰匙就不能進去,出來的時候把鑰匙放回門口。

這時候,門口的鑰匙數量就稱為訊號量(Semaphore)。很明顯,訊號量為0時需要等待,訊號量不為零時,減去1而且不等待。

在GCD中,建立訊號量的語法如下:

這句程式碼通過dispatch_semaphore_create方法建立一個訊號量並設定初始值為2。然後就可以呼叫dispatch_semaphore_wait方法了。

dispatch_semaphore_wait方法表示一直等待直到訊號量的值大於等於一,當這個方法執行後,會把第一個訊號量引數的值減1。

第二個引數是一個dispatch_time_t型別的時間,它表示這個方法最大的等待時間。這在第一章中已經講過,比如
DISPATCH_TIME_FOREVER表示永久等待。

返回值也和dispatch_group_wait方法一樣,返回0表示在規定的等待時間內第一個引數訊號量的值已經大於等於1,否則表示已超過規定等待時間,但訊號量的值還是0。

dispatch_semaphore_wait方法返回0,因為此時的訊號量的值大於等於一,任務獲得了可以執行的許可權。這時候我們就可以安全的執行需要進行排他控制的任務了。

任務結束時還需要呼叫
dispatch_semaphore_signal()方法,將訊號量的值加1。這類似於之前所說的,從臥室出來要把鎖放回門上,否則後來的人就無法進入了。

我們來看一個完整的例子:

如果你想知道不用訊號量會出什麼問題,可以看我的另一篇文章Swift陣列append方法研究

相關文章