GCD 使用指南

做者:Soroush Khanlou,原文連接,原文日期:2016-04-25
譯者:walkingway;校對:numbbbbb;定稿:numbbbbbhtml

Grand Central Dispatch 大中樞派發? 或俗稱 GCD 是一件極其強大的武器。它爲你提供了不少底層工具(好比隊列和信號量),你能夠組合這些工具來實現本身想要的多線程效果。不幸的是,這些基於 C 的 API 晦澀難懂,此外將低級工具組合起來實現高抽象層級 API(譯者注:相似於 NSOperation)也不是一件容易的事。在這篇文章中,我會教你們如何利用 GCD 提供的工具來實現高抽象層級的行爲。英文原文ios

後臺執行

這或許是 GCD 提供的最簡單的工具了,你能夠在後臺線程中處理一些工做,處理完畢後返回主線程繼續執行(由於 UIKit 相關的操做只能在主線程中進行)。編程

在本指南中,我將使用 doSomeExpensiveWork() 函數來表示一個長時間執行的任務,它會返回一個值。swift

這種模式的代碼以下所示:數組

let defaultPriority = DISPATCH_QUEUE_PRIORITY_DEFAULT
let backgroundQueue = dispatch_get_global_queue(defaultPriority, 0)
dispatch_async(backgroundQueue, {
    let result = doSomeExpensiveWork()
    dispatch_async(dispatch_get_main_queue(), {
        //使用 `result` 作各類事
    })
})

在實際項目中,除了 DISPATCH_QUEUE_PRIORITY_DEFAULT,咱們幾乎用不到其餘的優先級選項。dispatch_get_global_queue() 將返回一個隊列,其中可能有數百個線程在併發執行。若是你常常須要在後臺隊列執行開銷龐大的操做,那能夠用 dispatch_queue_create 建立本身的隊列,dispatch_queue_create 帶兩個參數,第一個是須要指定的隊列名,第二個說明是串行隊列仍是併發隊列。安全

注意,每次調用使用的是 dispatch_async 而不是 dispatch_syncdispatch_async 將在 block 執行前當即返回,而 dispatch_sync 則會等到 block 執行完畢後才返回。內部的調用可使用 dispatch_sync(由於不在意何時返回),可是外部的調用必須是 dispatch_async(不然主線程會被阻塞)。網絡

建立單例

dispatch_once 這個 API 能夠用來建立單例。不過這種方式在 Swift 中已再也不重要,Swift 有更簡單的方法來建立單例。我這裏就只貼 OC 的實現:多線程

objectivec
  • (instancetype) sharedInstance {併發

    static dispatch_once_t onceToken;  
       static id sharedInstance;  
       dispatch_once(&onceToken, ^{  
           sharedInstance = [[self alloc] init];  
       });  
       return sharedInstance;

    }app

攤平 completion block

至此咱們的 GCD 之旅開始變得有趣起來。咱們可使用信號量來阻塞一個線程任意時間,直到一個信號從另外一個線程發出。信號量和 GCD 的其餘部分同樣是線程安全的,而且可以從任意位置被觸發。

若是你想同步執行一個異步 API,那你可使用信號量,可是你不能修改它。

objectivec
// 在後臺隊列
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0)
doSomeExpensiveWorkAsynchronously(completionBlock: {
    dispatch_semaphore_signal(semaphore)
})
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
// 如今開銷很大的異步工做已經完成

調用 dispatch_semaphore_wait 會阻塞線程,直到 dispatch_semaphore_signal 被調用。這就意味着 signal 必須從不一樣的線程被調用,由於當前線程已經被阻塞。你永遠都不該該在主線程中調用 wait,只能在後臺線程中調用它。

你能夠在調用 dispatch_semaphore_wait 時設置一個超時時間,可是我通常會使用 DISPATCH_TIME_FOREVER

爲何在已有 completion block 的狀況下還要攤平代碼?由於方便呀,我能想到的一種場景是串行執行一組異步程序(即只有前一個任務執行完成,纔會繼續執行下一個任務)。下面把上述想法簡單地抽象成一個 AsyncSerialWorker 類:

typealias DoneBlock = () -> ()
typealias WorkBlock = (DoneBlock) -> ()

class AsyncSerialWorker {
    private let serialQueue = dispatch_queue_create("com.khanlou.serial.queue", DISPATCH_QUEUE_SERIAL)

    func enqueueWork(work: WorkBlock) {
        dispatch_async(serialQueue) {
            let semaphore = dispatch_semaphore_create(0)
            work({
                dispatch_semaphore_signal(semaphore)
            })
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
        }
    }
}

上面這個簡短的類建立了一個串行隊列,容許你將 work 的入隊列操做放進 block 中。WorkBlock 須要一個 DoneBlock 做爲參數,而 DoneBlock 會在當前工做結束時被執行,咱們經過將 DoneBlock 設置爲 {dispatch_semaphore_signal(semaphore)} 來調整信號量,從而讓串行隊列繼續執行下去。

譯者注:既然已經使用了 DISPATCH_QUEUE_SERIAL,那麼隊列中 work 的執行順序不該該是先進先出的嗎?確實是這樣,但若是咱們把 work 當作是一個耗時的網絡操做,其內部是提交到其餘線程併發去執行(dispatch_async),也就是每次執行到 work 就馬上返回了,即便最終結果可能還未返回。那麼咱們想要保證隊列中的 work 等到前一個 work 執行返回結果後才執行,就須要 semaphore。說了這麼多仍是舉個例子吧,打開 Playground:

import UIKit
import XCPlayground

typealias DoneBlock = () -> ()
typealias WorkBlock = (DoneBlock) -> ()

class AsyncSerialWorker {
    private let serialQueue = dispatch_queue_create("com.khanlou.serial.queue", DISPATCH_QUEUE_SERIAL)
    
    func enqueueWork(work: WorkBlock) {
        dispatch_async(serialQueue) {
            let semaphore = dispatch_semaphore_create(0)
            work({
                dispatch_semaphore_signal(semaphore)
            })
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
        }
    }
}

let a = AsyncSerialWorker()

for i in 1...5 {
    a.enqueueWork { doneBlock in
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
            sleep(arc4random_uniform(4)+1)
            print(i)
            doneBlock()
        }
    }
}

XCPlaygroundPage.currentPage.needsIndefiniteExecution = true

此時的輸出結果爲:1,2,3,4,5,若是將關於 semaphore 的代碼都註釋掉,結果就不會是按順序輸出了。

dispatch_semaphore_create(0) 當兩個線程須要協調處理某個事件時,咱們在這裏傳入 0;內部實際上是維護了一個計數器,下面會說到。

限制併發的數量

在上面的例子中,信號量被用做一個簡單的標誌,但它也能夠當成一個有限資源的計數器。若是你想針對某些特定的資源限制鏈接數,能夠這樣作:

class LimitedWorker {
    private let concurrentQueue = dispatch_queue_create("com.khanlou.concurrent.queue", DISPATCH_QUEUE_CONCURRENT)
    private let semaphore: dispatch_semaphore_t
    
    init(limit: Int) {
        semaphore = dispatch_semaphore_create(limit)
    }

    func enqueueWork(work: () -> ()) {
        dispatch_async(concurrentQueue) {
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
            work()
            dispatch_semaphore_signal(semaphore)
        }
    }
}

這個例子來自於蘋果官方的多線程編程指南,官方給出的解釋以下:

在建立信號量時,能夠限定資源的可用數。這個可用數(long 類型)會在信號量初始化時做爲參數傳入。每次等待信號量時,dispatch_semaphore_wait 都會消耗一次可用數,若是結果爲負,函數會告訴內核阻斷你的線程。另外一方面,dispatch_semaphore_signal 函數每次執行都會將該可用計數 + 1,以此來代表已經釋放了資源。若是此刻有由於等待可用資源而被阻隔的任務,系統會從等待的隊列中解鎖一個任務來執行。

這個效果相似 NSOperationQueuemaxConcurrentOperationCount。若是你使用原生的 GCD 隊列而不是 NSOperationQueue,你就能使用信號量來限制併發任務的數量。

值得注意是:每次調用 enqueueWork 都會將 work 提交到一個併發隊列,而該併發隊列收到任務就會丟出去執行,直到觸碰到信號量數量耗盡的天花板(work 入隊列的速度太快,dispatch_semaphore_wait 已經消耗完了全部的數量,而以前的 work 還未執行完畢,dispatch_semaphore_signal 不能增長信號量的可用數量)

等待許多併發任務完成

若是你有許多 blocks 任務要去執行,你須要在它們所有完成時獲得通知,那可使用 group。dispatch_group_async 容許你在隊列中添加任務(這些任務應該是同步執行的),並且你會追蹤有多少被添加的任務。注意:同一個 dispatch group 可以添加不一樣隊列上的任務,而且能保持對全部組內任務的追蹤。當全部被追蹤的任務完成時,一個傳遞給 dispatch_group_notifyblock 會被觸發執行,有點相似於 completion block

dispatch_group_t group = dispatch_group_create()
for item in someArray {
    dispatch_group_async(group, backgroundQueue, {
        performExpensiveWork(item: item)
    })
}
dispatch_group_notify(group, dispatch_get_main_queue(), {
    // 全部任務都已完成
}

這個例子很好地展現瞭如何攤平 completion block。Dispatch group 會在 block 返回時調用 completion block,因此你須要在 block 中等待全部任務完成。

下面這個例子更加詳細地展現了 dispatch group 的用法,若是你的任務已是異步,能夠這樣使用:

// 必須在後臺隊列使用
dispatch_group_t group = dispatch_group_create()
for item in someArray {
    dispatch_group_enter(group)
    performExpensiveAsyncWork(item: item, completionBlock: {
        dispatch_group_leave(group)
    })
}

dispatch_group_wait(group, DISPATCH_TIME_FOREVER)

// 全部任務都已完成

這段代碼更加複雜,不過認真閱讀仍是能看懂的。和信號量同樣,groups 一樣保持着一個線程安全的、能夠操控的內部計數器。你可使用這個計數器來確保在 completion block 執行前,多個大開銷任務都已執行完畢。使用 enter 來增長計數器,使用 leave 來減小計數器。dispatch_group_async 已爲你處理了這些細節,因此盡情地享受便可。

代碼片斷的最後一行是 wait 調用:它會阻斷當前線程而且等計數器到 0 時繼續執行。注意,雖然你使用了 enter/leave API,但你仍是可以經過 dispatch_group_notify 將 block 提交到隊列中。反過來也成立:若是你用了 dispatch_group_async API,也能使用 dispatch_group_wait

dispatch_group_waitdispatch_semaphore_wait 同樣接收一個超時參數。再次重申,我更喜歡 DISPATCH_TIME_FOREVER。另外,不要在主線程中調用 dispatch_group_wait

上面兩段代碼最大的區別是,notify 能夠在主線程中調用,而 wait 只能在後臺線程中調用(至少 wait 部分要在後臺線程中調用,由於它會徹底阻塞當前線程)。

隔離隊列

Swift 中的字典(和數組)都是值類型,當它們被修改時,它們的引用會被一個新的副本所替代。可是,由於更新 Swift 對象的實例變量操做並非原子性的,因此這些操做不是線程安全的。若是兩個線程同一時間更新一個字典(好比都添加一個值),並且這兩個操做都嘗試寫同一塊內存,這就會致使內存崩壞。咱們可使用隔離隊列來實現線程安全。

先構建一個標識映射 Identity Map,一個標識映射是一個字典,表示從 ID 到 model 對象的映射。

標識映射(Identity Map)模式將全部已加載對象放在一個映射中,確保全部對象只被加載一次,而且在引用這些對象時使用該映射來查找對象。在處理數據併發訪問時,須要一種策略讓多個用戶共同操做同一個業務實體,這個很重要。一樣重要的是,單個用戶在一個長運行事務或復瑣事務中始終使用業務實體的一致版本。標識映射模式會爲事務中使用全部的業務對象保存一個版本,若是一個實體被請求兩次,會獲得同一個實體。

class IdentityMap<T: Identifiable> {
    var dictionary = Dictionary<String, T>()
    
    func object(forID ID: String) -> T? {
        return dictionary[ID] as T?
    }
    
    func addObject(object: T) {
        dictionary[object.ID] = object
    }
}

這個對象基本就是一個字典封裝器,若是有多個線程在同一時刻調用函數 addObject,內存將會崩壞,由於線程會操做相同的引用。這也是操做系統中的經典的讀者-寫者問題,簡而言之,咱們能夠在同一時刻有多個讀者,但同一時刻只能有一個線程能夠寫入。

幸運的是 GCD 針對在該場景下一樣擁有強力武器,咱們可使用以下四個 API:

  • dispatch_sync

  • dispatch_async

  • dispatch_barrier_sync

  • dispatch_barrier_async

理想的狀況是,讀操做併發執行,寫操做異步執行而且必須確保沒有其餘操做同時執行。GCD 的 barrier 集合 API 提供瞭解決方案:它們會在隊列中的任務清空後執行 block。使用 barrier API 能夠限制咱們對字典對象的寫入,而且確保咱們不會在同一時刻執行多個寫操做,或者在執行寫操做同時執行讀操做。

class IdentityMap<T: Identifiable> {
    var dictionary = Dictionary<String, T>()
    let accessQueue = dispatch_queue_create("com.khanlou.isolation.queue", DISPATCH_QUEUE_CONCURRENT)
        
    func object(withID ID: String) -> T? {
        var result: T? = nil
        dispatch_sync(accessQueue, {
            result = dictionary[ID] as T?
        })
        return result
    }
        
    func addObject(object: T) {
        dispatch_barrier_async(accessQueue, {
            dictionary[object.ID] = object
        })
    }
}

dispatch_sync 將會分發 block 到咱們的隔離隊列上,而後等待其執行完畢。經過這種方式,咱們就實現了同步讀操做(若是咱們想異步讀取,getter 方法就須要一個 completion block)。由於 accessQueue 是併發隊列,這些同步讀取操做能夠併發執行,也就是容許同時讀。

dispatch_barrier_async 將分發 block 到隔離隊列上,async 異步部分意味着會當即返回,並不會等待 block 執行完畢。這對性能有好處,可是在一個寫操做後當即執行一個讀操做會致使讀到一個半成品的數據(由於可能寫操做還未完成就開始讀了)。

dispatch_barrier_asyncbarrier 部分的邏輯是:barrier block 進入隊列後不會當即執行,而是會等待該隊列其餘 block 執行完畢後再執行。這就保證了咱們的 barrier block 每次都只有它本身在執行。而全部在它以後提交的 block 也會一直等待這個 barrier block 執行完再執行。

傳入 dispatch_barrier_async() 函數的 queue,必須是 dispatch_queue_create 建立的併發 queue。若是是串行 queue 或者是 global concurrent queues,這個函數就變成 dispatch_async()

總結

GCD 是一個具有底層特性的框架,經過它,咱們能夠構建高層級的抽象行爲。若是還有一些我沒提到的能夠用 GCD 構建的高層行爲,請告訴我。

本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 http://swift.gg

相關文章
相關標籤/搜索