iOS多線程——GCD與NSOperation總結


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

新開一個線程,能提高速度,避免阻塞主線程

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

 

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

 

於是,帶着對“多線程”和“異步”的懵懂,很多人又開開心心踏上了多線程編程之旅,比如文章待會兒會提到的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的過程。

  • 串行隊列(先進入隊列的任務先出隊列,每次只執行一個任務) 
  • 並發隊列(依然是“先入先出”,不過可以形成多個任務並發) 
  • 主隊列(這是一個特殊的串行隊列,而且隊列中的任務一定會在主線程中執行)
  1. 同步執行 
  2. 異步執行

關於同步異步、串行並行和線程的關系,下面通過一個表格來總結

可以看到,同步方法不一定在本線程,異步方法方法也不一定新開線程(考慮主隊列)。

然而事實上,在本文一開始就揭開了“多線程”的神秘面紗,所以我們在編程時,更應該考慮的是:

同步 Or 異步以及串行 Or 並行,而非僅僅考慮是否新開線程。

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

 

在使用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的時候應該分析當前處於哪個隊列,以及任務會提交到哪個隊列。

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

在單個串行隊列中,這個不是問題,因為只要把回調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_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 的任務全部完成。

通過 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 和 NSOperationQueue 主要涉及這幾個方面:

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

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

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

不僅如此,NSOperation 表示的任務還可以被取消。它還有三種狀態 isExecutedisFinished和 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 是一個同步方法。

剛剛我們知道,默認的 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 的時候,我們總是用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 之間的相互依賴會導致死鎖

queue.suspended = true //暫停queue中所有operation  
queue.suspended = false //恢復queue中所有operation  

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

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

NSOperation 有一個NSOperationQueuePriority 枚舉類型的屬性 queuePriority

public enum NSOperationQueuePriority : Int {  
    case VeryLow
    case Low
    case Normal
    case High
    case VeryHigh
}

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

經過以上分析,我們大概對 NSOperation 和 GCD 都有了比較詳細的了解,同時在親自運用這兩者的過程中有了自己的理解。

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

而 NSOperation 可以用來規划一組任務之間的依賴關系,設置它們的優先級,任務能被取消。隊列可以暫停、恢復。NSOperation 還可以被子類化。這些都是 GCD 所不具備的。

所以我們要記住的是:

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

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

  • dispatch_suspend 和 dispatch_resume
  • dispathc_once
  • dispatch_barrier_async
  • dispatch_semaphore

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

dispatch_suspend(queue) //暫停某個隊列  
dispatch_resume(queue)  //恢復某個隊列  

 這些函數不會影響到隊列中已經執行的任務,隊列暫停后,已經添加到隊列中但還沒有執行的任務不會執行,直到隊列被恢復。

首先我們來看一下最簡單的 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 而已。

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

這樣的需求比較常見,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 會把並行隊列的運行周期分為這三個過程:

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

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

這樣一來,使用並行隊列和 dispatc_barrier_async 方法,就可以高效的進行數據和文件讀寫了。

首先介紹一下信號量(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)
    })
}

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM