很長時間以來,我個人(可能還有很多同學),對多線程編程都存在一些誤解。一個很明顯的表現是,很多人有這樣的看法:
新開一個線程,能提高速度,避免阻塞主線程
畢竟多線程嘛,幾個線程一起跑任務,速度快,還不阻塞主線程,簡直完美。
在某些場合,我們還見過另一個“高深”的名詞——“異步”。這東西好像和多線程挺類似,經過一番百度(閱讀了很多質量層次不齊的文章)之后,很多人也沒能真正搞懂何為“異步”。
於是,帶着對“多線程”和“異步”的懵懂,很多人又開開心心踏上了多線程編程之旅,比如文章待會兒會提到的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的部分會對此做出解釋。
iOS中多線程的實現方案
GCD簡介
GCD以block為基本單位,一個block中的代碼可以為一個任務。下文中提到任務,可以理解為執行某個block
同時,GCD中有兩大最重要的概念,分別是“隊列”和“執行方式”。
使用block的過程,概括來說就是把block放進合適的隊列,並選擇合適的執行方式去執行block的過程。
三種隊列:
- 串行隊列(先進入隊列的任務先出隊列,每次只執行一個任務)
- 並發隊列(依然是“先入先出”,不過可以形成多個任務並發)
- 主隊列(這是一個特殊的串行隊列,而且隊列中的任務一定會在主線程中執行)
兩種執行方式:
- 同步執行
- 異步執行
關於同步異步、串行並行和線程的關系,下面通過一個表格來總結
可以看到,同步方法不一定在本線程,異步方法方法也不一定新開線程(考慮主隊列)。
然而事實上,在本文一開始就揭開了“多線程”的神秘面紗,所以我們在編程時,更應該考慮的是:
同步 Or 異步以及串行 Or 並行,而非僅僅考慮是否新開線程。
當然,了解任務運行在那個線程中也是為了更加深入的理解整個程序的運行情況,尤其是接下來要討論的死鎖問題。
GCD的死鎖問題
在使用GCD的過程中,如果向當前串行隊列中同步派發一個任務,就會導致死鎖。
這句話有點繞,我們首先舉個例子看看:
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
了。
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_notify
方法。這個方法表示把第三個參數 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 。代碼如下:
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 對它的狀態進行監聽。
想要開始執行一個任務可以這么寫:
let operation = NSBlockOperation { () -> Void in print(NSThread.currentThread()) } operation.addExecutionBlock { () -> Void in print("execution block1 -- \(NSThread.currentThread())") } operation.start()
以上代碼會得到這樣的執行結果:
<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
實現異步操作呢,代碼如下:
let operationQueue = NSOperationQueue() let operation = NSBlockOperation () operation.addExecutionBlock { () -> Void in print("exec block1 -- \(NSThread.currentThread())") } operation.addExecutionBlock { () -> Void in print("exec block2 -- \(NSThread.currentThread())") } operation.addExecutionBlock { () -> Void in print("exec block3 -- \(NSThread.currentThread())") } operationQueue.addOperation(operation) print("操作結束")
得到運行結果如下:
操作結束 exec block1 -- <NSThread: 0x125672f10>{number = 2, name = (null)} exec block2 -- <NSThread: 0x12556ba40>{number = 3, name = (null)} exec block3 -- <NSThread: 0x125672f10>{number = 2, 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
。
//取消某個NSOperation operation1.cancel() //取消某個NSOperationQueue剩余的NSOperation queue.cencelAllOperations()
設置依賴
依然考慮剛剛所說的兩次網絡請求的例子。因為第二次請求會用到第一次的數據,所以我們要保證發出第二次請求的時候第一個請求已經執行完。但是我們同時還希望利用到 NSOperationQueue
的並發特性(因為可能不止這兩個任務)。
這時候我們可以設置 NSOperation
之間的依賴關系。語法非常簡潔:
operation2.addDependency(operation1)
需要注意的是 NSOperation
之間的相互依賴會導致死鎖
NSOperationQueue暫停與恢復
queue.suspended = true //暫停queue中所有operation queue.suspended = false //恢復queue中所有operation
這個更加簡單,只要修改 suspended
屬性即可
NSOperation優先級
GCD中,任務(block)是沒有優先級的,而隊列具有優先級。和GCD相反,我們一般考慮 NSOperation
的優先級
NSOperation
有一個NSOperationQueuePriority
枚舉類型的屬性 queuePriority
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中的隊列也有類似的功能。用法也非常簡單:
dispatch_suspend(queue) //暫停某個隊列 dispatch_resume(queue) //恢復某個隊列
這些函數不會影響到隊列中已經執行的任務,隊列暫停后,已經添加到隊列中但還沒有執行的任務不會執行,直到隊列被恢復。
dispathc_once
首先我們來看一下最簡單的 dispathc_once
函數,這在單例模式中被廣泛使用。
dispathc_once
函數可以確保某個 block 在應用程序執行的過程中只被處理一次,而且它是線程安全的。所以單例模式可以很簡單的實現,以 OC 中 Manager 類為例
+ (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
假設我們有四個讀取任務,在第二、三個任務之間有一個寫入任務,代碼大概是這樣:
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
函數,代碼就可以這么寫:
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中,創建信號量的語法如下:
var semaphore = dispatch_semaphore_create(2)
這句代碼通過 dispatch_semaphore_create
方法創建一個信號量並設置初始值為 2。然后就可以調用 dispatch_semaphore_wait
方法了。
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
dispatch_semaphore_wait
方法表示一直等待直到信號量的值大於等於 1,當這個方法執行后,會把第一個信號量參數的值減 1。
第二個參數是一個 dispatch_time_t
類型的時間,它表示這個方法最大的等待時間。這在第一章中已經講過,比如 DISPATCH_TIME_FOREVER
表示永久等待。
返回值也和 dispatch_group_wait
方法一樣,返回 0 表示在規定的等待時間內第一個參數信號量的值已經大於等於 1,否則表示已超過規定等待時間,但信號量的值還是 0。
dispatch_semaphore_wait
方法返回 0,因為此時的信號量的值大於等於一,任務獲得了可以執行的權限。這時候我們就可以安全的執行需要進行排他控制的任務了。
任務結束時還需要調用 dispatch_semaphore_signal()
方法,將信號量的值加 1。這類似於之前所說的,從卧室出來要把鎖放回門上,否則后來的人就無法進入了。
我們來看一個完整的例子:
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) }) }