很長時間以來,我個人(可能還有很多同學),對多執行緒程式設計都存在一些誤解。一個很明顯的表現是,很多人有這樣的看法:
新開一個執行緒,能提高速度,避免阻塞主執行緒
畢竟多執行緒嘛,幾個執行緒一起跑任務,速度快,還不阻塞主執行緒,簡直完美。
在某些場合,我們還見過另一個“高深”的名詞——“非同步”。這東西好像和多執行緒挺類似,經過一番百度(閱讀了很多質量層次不齊的文章)之後,很多人也沒能真正搞懂何為“非同步”。
於是,帶著對“多執行緒”和“非同步”的懵懂,很多人又開開心心踏上了多執行緒程式設計之旅,比如文章待會兒會提到的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的過程。
三種佇列:
- 序列佇列(先進入佇列的任務先出佇列,每次只執行一個任務)
- 並行佇列(依然是“先入先出”,不過可以形成多個任務併發)
- 主佇列(這是一個特殊的序列佇列,而且佇列中的任務一定會在主執行緒中執行)
兩種執行方式:
- 同步執行
- 非同步執行
關於同步非同步、序列並行和執行緒的關係,下面通過一個表格來總結
可以看到,同步方法不一定在本執行緒,非同步方法方法也不一定新開執行緒(考慮主佇列)。
然而事實上,在本文一開始就揭開了“多執行緒”的神祕面紗,所以我們在程式設計時,更應該考慮的是:
同步 Or 非同步
以及
序列 Or 並行
而非僅僅考慮是否新開執行緒。
當然,瞭解任務執行在那個執行緒中也是為了更加深入的理解整個程式的執行情況,尤其是接下來要討論的死鎖問題。
GCD的死鎖問題
在使用GCD的過程中,如果向當前序列佇列中同步派發一個任務,就會導致死鎖。
這句話有點繞,我們首先舉個例子看看:
1 2 3 4 5 6 7 8 |
override func viewDidLoad() { super.viewDidLoad() let mainQueue = dispatch_get_main_queue() let block = { () in print(NSThread.currentThread()) } dispatch_sync(mainQueue, block) } |
這段程式碼就會導致死鎖,因為我們目前在主佇列中,又將要同步地新增一個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
了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
let group = dispatch_group_create() dispatch_group_async(group, serialQueue, { () -> Void in for _ in 0..<2 { print("group-serial (NSThread.currentThread())") } }) dispatch_group_async(group, serialQueue, { () -> Void in for _ in 0..<3 { NSLog("group-02 - %@", NSThread.currentThread()) } }) dispatch_group_notify(group, serialQueue, { () -> Void in print("完成 - (NSThread.currentThread())") }) |
首先我們要通過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_NOW
和DISPATCH_TIME_FOREVER
。
前者表示立刻檢查屬於這個group的任務是否已經完成,後者則表示一直等到屬於這個group的任務全部完成。
dispatch_after方法
通過GCD還可以進行簡單的定時操作,比如在1秒後執行某個block。程式碼如下:
1 2 3 4 |
let mainQueue = dispatch_get_main_queue() let time = dispatch_time(DISPATCH_TIME_NOW, Int64(3) * Int64(NSEC_PER_SEC)) NSLog("%@",NSThread.currentThread()) dispatch_after(time, mainQueue, {() in NSLog("%@",NSThread.currentThread())}) |
和dispatch_after
方法有三個引數。第一個表示時間,也就是從現在起往後三秒鐘。第二三個引數分別表示要提交的任務和提交到哪個佇列。
需要注意的是和dispatch_after
僅表示在指定時間後提交任務,而非執行任務。如果任務提交到主佇列,它將在main runloop中執行,對於每隔1/60秒執行一次的RunLoop,任務最多有可能在3+1/60秒後執行。
NSOperation
NSOperation
和NSOperationQueue
主要涉及這幾個方面:
NSOperation
和和NSOperationQueue
用法介紹NSOperation
的暫停、恢復和取消- 通過KVO對
NSOperation
的狀態進行檢測 - 多個
NSOperation
的之間的依賴關係
從簡單意義上來說,NSOperation
是對GCD中的block進行的封裝,它也表示一個要被執行的任務。
與GCD中的block類似,NSOperation
物件有一個start()
方法表示開始執行這個任務。
不僅如此,NSOperation
表示的任務還可以被取消。它還有三種狀態isExecuted
、isFinished
和isCancelled
以方便我們通過KVC對它的狀態進行監聽。
想要開始執行一個任務可以這麼寫:
1 2 3 4 5 6 7 |
let operation = NSBlockOperation { () -> Void in print(NSThread.currentThread()) } operation.addExecutionBlock { () -> Void in print("execution block1 -- (NSThread.currentThread())") } operation.start() |
以上程式碼會得到這樣的執行結果:
1 2 |
<NSThread: 0x7f89b1c070f0>{number = 1, name = main} execution block1 -- <NSThread: 0x7f89b1e17030>{number = 2, name = (null)} |
首先我們建立了一個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
實現非同步操作呢,程式碼如下:
1 2 3 4 5 6 7 8 9 |
let operationQueue = NSOperationQueue() let operation = NSBlockOperation { () -> Void in print(NSThread.currentThread()) } operation.addExecutionBlock { () -> Void in print("execution block1 -- (NSThread.currentThread())") } operationQueue.addOperation(operation) print("操作結束") |
得到執行結果如下:
1 2 3 |
操作結束 <NSThread: 0x7fd51d2111b0>{number = 2, name = (null)} execution block1 -- <NSThread: 0x7fd51d21bd50>{number = 3, name = (null)} |
使用NSOperationQueue
來執行任務與之前的區別在於,首先建立一個非主佇列。然後用addOperation
方法替換之前的start
方法。剛剛已經說過,NSOperationQueue
會為每一個NSOperation
建立執行緒並呼叫他們的start
方法。
觀察一下執行結果,所有的NSOperation
都沒有在主執行緒執行,從而成功的實現了非同步、並行處理。
NSOperation新特性
在學習NSOperation
的時候,我們總是用GCD的概念去解釋。但是NSOperation
作為對GCD更高層次的封裝,它有著一些GCD無法實現(或者至少說很難實現)的特性。由於NSOperation
和NSOperationQueue
良好的封裝,這些新特性的使用都非常簡單。
取消任務
如果我們有兩次網路請求,第二次請求會用到第一次的資料。如果此時網路情況不好,第一次請求超時了,那麼第二次請求也沒有必要傳送了。當然,使用者也有可能人為地取消某個NSOperation
。
當某個NSOperation
被取消時,我們應該儘可能的清除NSOperation
內部的資料並且把cancelled
和finished
設為true
,把executing
設為false
。
1 2 3 4 5 |
//取消某個NSOperation operation1.cancel() //取消某個NSOperationQueue剩餘的NSOperation queue.cencelAllOperations() |
設定依賴
依然考慮剛剛所說的兩次網路請求的例子。因為第二次請求會用到第一次的資料,所以我們要保證發出第二次請求的時候第一個請求已經執行完。但是我們同時還希望利用到NSOperationQueue
的併發特性(因為可能不止這兩個任務)。
這時候我們可以設定NSOperation
之間的依賴關係。語法非常簡潔:
1 |
operation2.addDependency(operation1) |
需要注意的是NSOperation
之間的相互依賴會導致死鎖
NSOperationQueue暫停與恢復
這個更加簡單,只要修改suspended
屬性即可
1 2 |
queue.suspended = true //暫停queue中所有operation queue.suspended = false //恢復queue中所有operation |
NSOperation優先順序
GCD中,任務(block)是沒有優先順序的,而佇列具有優先順序。和GCD相反,我們一般考慮NSOperation
的優先順序
NSOperation
有一個NSOperationQueuePriority
列舉型別的屬性queuePriority
1 2 3 4 5 6 7 |
public enum NSOperationQueuePriority : Int { case VeryLow case Low case Normal case High case VeryHigh } |
需要注意的是,NSOperationQueue
也不能完全保證優先順序高的任務一定先執行。
NSOperation和GCD如何選擇
其實經過這兩篇文章的分析,我們大概對NSOperation
和GCD
都有了比較詳細的瞭解,同時在親自運用這兩者的過程中有了自己的理解。
GCD以block為單位,程式碼簡潔。同時GCD中的佇列、組、訊號量、source、barriers都是組成並行程式設計的基本原語。對於一次性的計算,或是僅僅為了加快現有方法的執行速度,選擇輕量化的GCD就更加方便。
而NSOperation
可以用來規劃一組任務之間的依賴關係,設定它們的優先順序,任務能被取消。佇列可以暫停、恢復。NSOperation
還可以被子類化。這些都是GCD所不具備的。
所以我們要記住的是:
NSOperation
和GCD並不是互斥的,有效地結合兩者可以開發出更棒的應用
GCD進階
NSOperation
有自己獨特的優勢,GCD
也有一些強大的特性。接下來我們由淺入深,討論以下幾個部分:
dispatch_suspend
和dispatch_resume
dispathc_once
dispatch_barrier_async
dispatch_semaphore
dispatch_suspend和dispatch_resume
我們知道NSOperationQueue
有暫停(suspend)和恢復(resume)。其實GCD中的佇列也有類似的功能。用法也非常簡單:
1 2 |
dispatch_suspend(queue) //暫停某個佇列 dispatch_resume(queue) //恢復某個佇列 |
這些函式不會影響到佇列中已經執行的任務,佇列暫停後,已經新增到佇列中但還沒有執行的任務不會執行,直到佇列被恢復。
dispathc_once
首先我們來看一下最簡單的dispathc_once
函式,這在單例模式中被廣泛使用。
dispathc_once
函式可以確保某個block在應用程式執行的過程中只被處理一次,而且它是執行緒安全的。所以單例模式可以很簡單的實現,以OC中Manager類為例
1 2 3 4 5 6 7 8 9 10 |
+ (Manager *)sharedInstance { static Manager *sharedManagerInstance = nil; static dispatch_once_t once; dispatch_once($once, ^{ sharedManagerInstance = [[Manager alloc] init]; }); return sharedManagerInstance; } |
這段程式碼中我們建立一個值為nil的sharedManagerInstance
靜態物件,然後把它的初始化程式碼放到dispatch_once
中完成。
這樣,只有第一次呼叫sharedInstance
方法時才會進行物件的初始化,以後每次只是返回sharedManagerInstance
而已。
dispatch_barrier_async
我們知道資料在寫入時,不能在其他執行緒讀取或寫入。但是多個執行緒同時讀取資料是沒有問題的。所以我們可以把讀取任務放入並行佇列,把寫入任務放入序列佇列,並且保證寫入任務執行過程中沒有讀取任務可以執行。
這樣的需求比較常見,GCD提供了一個非常簡單的解決辦法——dispatch_barrier_async
假設我們有四個讀取任務,在第二三個任務之間有一個寫入任務,程式碼大概是這樣:
1 2 3 4 5 6 7 8 9 10 11 12 |
let queue = dispatch_queue_create("com.gcd.kt", DISPATCH_QUEUE_CONCURRENT) dispatch_async(queue, block1_for_reading) dispatch_async(queue, block2_for_reading) /* 這裡插入寫入任務,比如: dispatch_async(queue, block_for_writing) */ dispatch_async(queue, block3_for_reading) dispatch_async(queue, block4_for_reading) |
如果程式碼這樣寫,由於這幾個block是併發執行,就有可能在前兩個block中讀取到已經修改了的資料。如果是有多寫入任務,那問題更嚴重,可能會有資料競爭。
如果使用dispatch_barrier_async
函式,程式碼就可以這麼寫:
1 2 3 4 5 6 7 |
dispatch_async(queue, block1_for_reading) dispatch_async(queue, block2_for_reading) dispatch_barrier_async(queue, block_for_writing) dispatch_async(queue, block3_for_reading) dispatch_async(queue, block4_for_reading) |
dispatch_barrier_async
會把並行佇列的執行週期分為這三個過程:
- 首先等目前追加到並行佇列中所有任務都執行完成
- 開始執行
dispatch_barrier_async
中的任務,這時候即使向並行佇列提交任務,也不會執行 dispatch_barrier_async
中的任務執行完成後,並行佇列恢復正常。
總的來說,dispatch_barrier_async
起到了“承上啟下”的作用。它保證此前的任務都先於自己執行,此後的任務也遲於自己執行。正如barrier的含義一樣,它起到了一個柵欄、或是分水嶺的作用。
這樣一來,使用並行佇列和dispatc_barrier_async
方法,就可以高效的進行資料和檔案讀寫了。
dispatch_semaphore
首先介紹一下訊號量(semaphore)的概念。訊號量是持有計數的訊號,不過這麼解釋等於沒解釋。我們舉個生活中的例子來看看。
假設有一個房子,它對應程式的概念,房子裡的人就對應著執行緒。一個程式可以包括多個執行緒。這個房子(程式)有很多資源,比如花園、客廳等,是所有人(執行緒)共享的。
但是有些地方,比如臥室,最多隻有兩個人能進去睡覺。怎麼辦呢,在臥室門口掛上兩把鑰匙。進去的人(執行緒)拿著鑰匙進去,沒有鑰匙就不能進去,出來的時候把鑰匙放回門口。
這時候,門口的鑰匙數量就稱為訊號量(Semaphore)。很明顯,訊號量為0時需要等待,訊號量不為零時,減去1而且不等待。
在GCD中,建立訊號量的語法如下:
1 |
var semaphore = dispatch_semaphore_create(2) |
這句程式碼通過dispatch_semaphore_create
方法建立一個訊號量並設定初始值為2。然後就可以呼叫dispatch_semaphore_wait
方法了。
1 |
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) |
dispatch_semaphore_wait
方法表示一直等待直到訊號量的值大於等於一,當這個方法執行後,會把第一個訊號量引數的值減1。
第二個引數是一個dispatch_time_t
型別的時間,它表示這個方法最大的等待時間。這在第一章中已經講過,比如
DISPATCH_TIME_FOREVER
表示永久等待。
返回值也和dispatch_group_wait
方法一樣,返回0表示在規定的等待時間內第一個引數訊號量的值已經大於等於1,否則表示已超過規定等待時間,但訊號量的值還是0。
dispatch_semaphore_wait
方法返回0,因為此時的訊號量的值大於等於一,任務獲得了可以執行的許可權。這時候我們就可以安全的執行需要進行排他控制的任務了。
任務結束時還需要呼叫
dispatch_semaphore_signal()
方法,將訊號量的值加1。這類似於之前所說的,從臥室出來要把鎖放回門上,否則後來的人就無法進入了。
我們來看一個完整的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
var semaphore = dispatch_semaphore_create(1) let queue = dispatch_queue_create("com.gcd.kt", DISPATCH_QUEUE_CONCURRENT) var array: [Int] = [] for i in 1...100000 { dispatch_async(queue, { () -> Void in /* 某個執行緒執行到這裡,如果訊號量值為1,那麼wait方法返回1,開始執行接下來的操作。 與此同時,因為訊號量變為0,其它執行到這裡的執行緒都必須等待 */ dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) /* 執行了wait方法後,訊號量的值變成了0。可以進行接下來的操作。 這時候其它執行緒都得等待wait方法返回。 可以對array修改的執行緒在任意時刻都只有一個,可以安全的修改array */ array.append(i) /* 排他操作執行結束,記得要呼叫signal方法,把訊號量的值加1。 這樣,如果有別的執行緒在等待wait函式返回,就由最先等待的執行緒執行。 */ dispatch_semaphore_signal(semaphore) }) } |
如果你想知道不用訊號量會出什麼問題,可以看我的另一篇文章Swift陣列append方法研究