(翻譯)給iOS開發者的GCD使用手冊

little_xia發表於2017-12-13

Grand Central Dispatch,或者GCD,是一個極其強大的工具。它給你一些底層的元件,像佇列和訊號量,讓你可以通過一些有趣的方式來獲得有用的多執行緒效果。可惜的是,這個基於C的API是一個有點神祕,它不會明顯的告訴你如何使用這個底層元件來實現更高層次的方法。在這篇文章中,我希望描述那些你可以通過GCD提供給你的底層元件來實現的一些用法。

後臺工作

也許最簡單的用法,GCD讓你在後臺執行緒上做一些工作,然後回到主執行緒繼續處理,因為像那些屬於 UIKit 的元件只能(主要)在主執行緒中使用。

在本指南中,我將使用 doSomeExpensiveWork() 方法來表示一些長時間執行的有返回值的任務。

這種模式可以像這樣建立起來:

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(), {
		//use `result` somehow
	})
})
複製程式碼

在實踐中,我從不使用任何佇列優先順序除了 DISPATCH_QUEUE_PRIORITY_DEFAULT 。這返回一個佇列,它可以支援數百個執行緒的執行。如果你的耗效能的工作總是在一個特定的後臺佇列中發生,你也可用通過 dispatch_queue_create 方法來建立自己的佇列。 dispatch_queue_create 可以建立一個任意名稱的佇列,無論它是序列的還是並行的。

注意每一個呼叫使用 dispatch_async ,不使用 dispatch_syncdispatch_async 在 block 執行前返回,而 dispatch_sync 會等到 block 執行完畢才返回。內部的呼叫可以使用 dispatch_sync(因為不管它什麼時候返回),但外部必須呼叫 dispatch_async (否則,主執行緒會被阻塞)。

建立單例

dispatch_once 是一個可以被用來建立單例的API。在 Swift 中它不再是必要的,因為 Swift 中有一個更簡單的方法來建立單例。為了以後,當然,我把它寫在這裡(用 Objective-C )。

+ (instancetype) sharedInstance {  
	static dispatch_once_t onceToken;  
	static id sharedInstance;  
	dispatch_once(&onceToken, ^{  
		sharedInstance = [[self alloc] init];  
	});  
	return sharedInstance;  
}  
複製程式碼

扁平化一個完整的block

現在 GCD 開始變得有趣了。使用一個訊號量,我們可以讓一個執行緒暫停任意時間,直到另一個執行緒向它傳送一個訊號。這個訊號量,就像 GCD 其餘部分一樣,是執行緒安全的,並且他們可以從任何地方被觸發。

當你需要去同步一個你不能修改的非同步API時,你可以使用訊號量解決問題。

// on a background queue
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0)
doSomeExpensiveWorkAsynchronously(completionBlock: {
    dispatch_semaphore_signal(semaphore)
})
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
//the expensive asynchronous work is now done
複製程式碼

dispatch_semaphore_wait 會阻塞執行緒直到 dispatch_semaphore_signal 被呼叫。這就意味著 signal 一定要在另外一個執行緒中被呼叫,因為當前執行緒被完全阻塞。此外,你不應該在在主執行緒中呼叫 wait ,只能在後臺執行緒。

在呼叫 dispatch_semaphore_wait 時你可以選擇任意的超時時間,但是我傾向於一直使用 DISPATCH_TIME_FOREVER

這可能不是完全顯而易見的,為什麼你要把已有的一個完整的 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)
        }
    }
}
複製程式碼

這一小類可以建立一個序列佇列,並允許你將工作新增到 block 中。當你的工作完成後, WorkBlock 會呼叫 DoneBlock ,開啟訊號量,並允許序列佇列繼續。

限制併發 block 的數量。

在前面的例子中,訊號量作為一個簡單的標誌,但它也可以被用來作為一種有限的資源計數器。如果你想在一個特定資源上開啟特定數量的連線,你可以使用下面的程式碼:

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)
        }
    }
}
複製程式碼

這個例子從蘋果的Concurrency Programming Guide拿來的。他們可以更好的解釋在這裡發生了什麼:

當你建立一個訊號量時,你可以指定你的可用資源的數量。這個值是訊號量的初始計數變數。你每一次等待訊號量傳送訊號時,這個 dispatch_semaphore_wait 方法使計數變數遞減1。如果產生的值是負的,則函式告訴核心來阻止你的執行緒。在另一端,這個 dispatch_semaphore_signal 函式遞增count變數用1表示資源已被釋放。如果有任務阻塞和等待資源,其中一個隨即被放行並進行它的工作。

其效果類似於 maxConcurrentOperationCountNSOperationQueue 。如果你使用原 GCD隊 列而不是 NSOperationQueue,你可以使用訊號莊主來限制同時執行的 block 數量。

一個值得注意的就是,每次你呼叫 enqueueWork ,如果你開啟訊號量的限制,就會啟動一個新執行緒。如果你有一個低限並且大量工作的佇列,您可以建立數百個執行緒。一如既往,先配置檔案,然後更改程式碼。

等待許多併發任務來完成

如果你有多 block 工作來執行,並且在他們集體完成時你需要發一個通知,你可以使用 group 。dispatch_group_async 允許你在佇列中新增工作(在 block 裡面的工作應該是同步的),並且記錄新增了多少了專案。注意,在同一個 dispatch group 中可以將工作新增到不同的佇列中,並且可以跟蹤它們。當所有跟蹤的工作完成,這個 block 開始執行 dispatch_group_notify ,就像是一個完整的 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(), {
	// all the work is complete
}
複製程式碼

擁有一個完整的block,對於扁平化一個功能來說是一個很好的案例。 dispatch group 認為,當它返回時,這個 block 應該完成了,所以你需要這個 block 等待直到其他工作已經完成。

有更多的手動方式來使用 dispatch groups ,特別是如果你耗效能的工作已經是非同步的:

// must be on a background thread
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)

// all the work is complete
複製程式碼

這段程式碼是比較複雜的,但通過一行一行的閱讀可以幫助理解它。就像訊號量,groups 也還保持執行緒安全,是一個你可以操作的內部計數器。您可以使用此計數器來確保在執行完成 block 之前,多個長的執行任務都已完成。使用 “enter” 遞增計數器,並用 “leave” 遞減計數器。 dispatch_group_async 為你處理所有的這些細節,所以我願意儘可能的使用它。

在這段程式碼的最後一點是 wait 方法:它會阻塞執行緒,並等待計數器為0後,繼續執行。注意,即使你使用了enter/leave API,你也可以在在佇列中新增一個 dispatch_group_notify block.反過來也是對的:當你使用 dispatch_group_async API時你也可以使用 dispatch_group_wait

dispatch_group_wait,就像dispatch_semaphore_wait一樣,可以設定超時。再一次宣告,DISPATCH_TIME_FOREVER 已非常足夠使用, 我從未覺得需要使用其他的來設定超時。當然就像 dispatch_semaphore_wait 一樣,永遠不要在主執行緒使用 dispatch_group_wait

兩者之間最大的區別是,使用 notify 可以完全從主執行緒呼叫,而使用 wait,必須發生在後臺佇列(至少 wait 的部分,因為它會完全阻塞當前佇列)。

隔離佇列

Swift 語言的 Dictionary (和 Array )型別都是值型別。 當他們被改變時, 他們的引用會完全被新的結構給替代。當然,因為更新例項變數的 Swift 物件不是原子性的,它們不是執行緒安全的。雙執行緒可以在同一時間更新一個字典(例如,增加一個值),並且兩個嘗試寫在同一塊記憶體,這可能導致記憶體損壞。我們可以使用隔離佇列來實現執行緒安全。 讓我們建立一個identity map。 identity map 是一個字典,將專案從其ID 屬性對映到模型物件。

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 同一時間被多個執行緒所呼叫,它可能會損害記憶體,因為這些執行緒對對同一個引用進行處理。這被稱之為 readers-writers problem。總之,我們可以同時有多個讀者閱讀,但是隻有一個執行緒可以在任何給定的時間寫。 幸運的是,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 方法可能需要一個完成的 block 。)因為 accessQueue 是併發的,這些同步讀取就能同時發生。 dispatch_barrier_async 將 block 新增到隔離佇列。這個 async 部分意味著它將實際執行的 block 之前返回(執行寫入操作)。這對我們的表現有好處,但也有一個缺點是,在 “write” 操作後立即執行 “read” 操作可能會導致獲取改變之前的舊資料。 這個 dispatch_barrier_asyncbarrier 部分,意味著它將等待直到當前執行佇列中的每個 block 執行完畢後才執行。其他 block 將在它後面排隊,當barrier排程完成時執行。

總結

Grand Central Dispatch 是一個有很多底層語言的框架。使用它們,這個是我能建立的比較高階的技術。如果有其他一些你使用的GCD的高階用法而我沒有羅列在這裡,我喜歡聽到它們並將它們新增到列表中。

相關文章