[譯] Swift 中關於併發的一切:第一部分 — 當前

DeepMissea發表於2017-06-29

Swift 中關於併發的一切:第一部分 — 當前

在 Swift 語言的當前版本中,並沒有像其他現代語言如 Go 或 Rust 一樣,包含任何原生的併發功能。

如果你計劃非同步執行任務,並且需要處理由此產生的競爭條件時,你唯一的選擇就是使用外部庫,比如 libDispatch,或者 Foundation 和 OS 提供的同步原語。

在本系列教程的第一部分,我們會介紹 Swift 3 提供的功能,涵蓋一切,從基礎鎖、執行緒和計時器,到語言守護和最近改善的 GCD 和操作佇列。

我們也會介紹一些基礎的併發概念和一些常見的併發模式。

klingon 示例程式碼中的關鍵部分
klingon 示例程式碼中的關鍵部分

即使 pthread 庫的函式和原語可以在任一個執行 Swift 的平臺上使用,我們也不會在這裡討論,因為對於每個平臺,都有更高階的方案。

NSTimer 類也不會在這裡介紹,你可以看一看這裡,來了解如何在 Swift 3 中使用它。

就像已多次公佈的,Swift 4.0 之後的主要版本之一(不一定是 Swift 5)會擴充套件語言的功能,更好地定義記憶體模型,幷包含了新的原生併發功能,可以不需要藉助外部庫來處理併發,實現並行化,定義了一種 Swift 方式來實現併發。

這是本系列下一篇文章討論的內容,我們會討論一些其他語言實現的替代方法和正規化實現,和在 Swift 中他們是如何實現的。並且我們會分析一些用當前版本 Swift 完成的開源實現,這些實現中我們可以使用 Actor 正規化,Go 的 CSP 通道,軟體事務記憶體等特性。

第二篇文章將會完全是推測性的,它主要的目的是為你介紹這些主題,以便你以後可以參與到更熱烈討論當中,而這些討論將會定義未來 Swift 版本的併發是怎麼處理的。

本文或其他文章的 playground 可以在 GitHubZipped 找到。

目錄

多執行緒與併發入門

現在,無論你構建的是哪一種應用,你遲早會考慮應用在多執行緒環境執行的情況。

具有多個處理器或者多核處理器的計算平臺已經存在了幾十年,而像 threadprocess 這樣的概念甚至更久。

作業系統已經通過各種方式開放了這些能力給使用者的程式,每個現代的框架或者應用都會實現一些涉及多執行緒的廣為人知的設計模式,來提高程式的效能與靈活性。

在我們開始鑽研如何處理 Swift 併發的細節之前,讓我先簡要地解釋幾個你需要知道的概念,然後再開始考慮你是使用
Dispatch Queues 還是 Operation Queues

首先,你可能會問,雖然 Apple 的平臺和框架使用了執行緒,但是我為什麼要在自己的應用中引入它們呢?

有一些常見的情況,讓多執行緒的使用合情合理:

  • 任務組分離: 執行緒能從執行流程的角度,模組化你的程式。不同的執行緒用可預測方式,執行一組相同的任務,把他們與你程式的其他執行流程部分隔離,這樣你會更容易理解程式當前的狀態。

  • 獨立資料的計算並行化: 可以使用由硬體執行緒支援的多個軟體執行緒(可以參考下一條),來並行化在原始輸入資料結構的子集上執行的相同任務的多個副本。

  • 等待條件達成或 I/O 的一種簡潔的實現方式: 在執行 I/O 阻塞或其他型別的阻塞操作時,可以使用後臺執行緒來乾淨地等待這些操作完成。使用執行緒可以改進你程式的整體設計,並且使處理阻塞問題變成細枝末節的事情。

但是,當多個執行緒執行你應用的程式碼時,一些從單執行緒的角度看起來無意義的假設就變得非常重要了。

在每個執行緒都獨立地執行且沒有資料共享的完美情況下,併發程式設計實際上並不比編寫單執行緒執行的程式碼複雜多少。但是,就像經常發生的那樣,你打算用多個執行緒操作同一資料,那就需要一種方式來規劃對這些資料結構的訪問,以確保該資料上的每個操作都按預期完成,而不會與其他執行緒有任何的互動操作。

併發程式設計需要來自語言和作業系統的額外保證,需要明確地說明在多個執行緒同時訪問變數(或更一般的稱之為“資源”)並嘗試修改他們的值時,他們的狀態是如何變化的。

語言需要定義一個記憶體模型,一組規則明確地列出在併發執行緒的執行下一些基本語句的行為,並且定義如何共享記憶體以及哪種記憶體訪問是有效的。

多虧了這個(記憶體模型),使用者有了一個執行緒執行行為可預知的語言,並且我們知道編譯器將僅對遵循記憶體模型中定義的內容進行優化。

定義記憶體模型是語言進化的一個精妙的步驟,因為太嚴格的模型可能會限制編譯器的自身發展。對於記憶體模型的過去策略,新的巧妙的優化會變得無效。

定義記憶體模型的例子:

  • 語言中哪些語句可以被認為是原子性的,哪些不是,哪些操作只能作為一個整體執行,其它執行緒看不到中間結果。比如必須知道變數是否被原子地初始化。

  • 如何處理變數線上程之間的共享,他們是否被預設快取,以及他們是否會對被特定語言修飾符修飾的快取行為產生影響。

  • 例如,用於標記和規劃訪問關鍵部分(那些操作共享資源的程式碼塊)的併發操作符一次只允許一個執行緒訪問一個特定的程式碼路徑。

現在讓我們回頭聊聊在你程式中併發的使用。

為了正確處理併發問題,你要標識程式中的關鍵部分,然後用併發原語或併發化的資料結構來規劃資料在不同執行緒之間的共享。

對程式碼或資料結構這些部分的強制訪問規則開啟了另一組問題,這些源於事實的問題就是,雖然期望的結果是每個執行緒都能夠被執行,並有機會修改共享資料,但是在某些情況下,其中一些可能根本無法執行,或者資料可能以意想不到的和不可預測的方式改變。

你將面臨一系列額外的挑戰,並且必須處理一些常見的問題:

  • 競爭條件: 同一資料上多個執行緒的操作,例如併發地讀寫,一系列操作的執行結果可能會變得無法預測,並且依賴於執行緒的執行順序。

  • 資源爭奪: 多個執行緒執行不同的任務,在嘗試獲取相同資源的時候,會增加安全獲取所需資源的時間。獲取這些資源延誤的這些時間可能會導致意想不到的行為,或者可能需要你構建程式來規劃對這些資源的訪問。

  • 死鎖: 多執行緒之間互相等待對方釋放他們需要的資源/鎖,這組執行緒將永遠的被阻塞。

  • (執行緒)飢餓: 一個永遠無法獲取資源,或者一組有特定的順序資源的執行緒,由於各種原因,它需要不斷嘗試去獲取他們卻永遠失敗。

  • 優先順序反轉: 具有低優先順序的執行緒持續獲取高優先順序執行緒所需的資源,實質地反轉了系統指定的優先順序。

  • 非決定論與公平性: 我們無法對執行緒獲取資源的時間和順序做出臆斷,這個延遲無法事前確定,而且它嚴重的受到執行緒間爭奪的影響,執行緒甚至從不能獲得一個資源。但是用於守護關鍵部分的併發原語也可以用來構建公平(fair)或者支援公平(fairness),確保所有等待的執行緒都能夠訪問關鍵部分,並且遵守請求順序。

語言守護

即使在 Swift 語言本身沒有併發性相關功能的時期,它仍然提供了一些有關如何訪問屬性的保證。

例如全域性變數的初始化是原子性地,我們從不需要手動處理多個執行緒初始化同一個全域性變數的併發情況,或者擔心初始化還在進行的過程中看到一個只初始化了一部分的變數。

在下次討論單例的實現時,我們會繼續討論這個特性。

但要記住的重要一點是,延遲屬性的初始化並不是原子執行的,現在版本的語言並沒有提供註釋或修飾符來改變這一行為。

類屬性的訪問也不是原子的,如果你需要訪問,那你不得不實現手動獨佔式的訪問,使用鎖或類似的機制。

執行緒

Foundation 提供了 Thread 類,內部基於 pthread,可以用來建立新的執行緒並執行閉包。

執行緒可以使用 Thread 類中的 detachNewThreadSelector:toTarget:withObject: 函式來建立,或者我們可以建立一個新的執行緒,宣告一個自定義的 Thread 類,然後覆蓋 main() 函式:

classMyThread : Thread {
    override func main(){
        print("Thread started, sleep for 2 seconds...")
        sleep(2)
        print("Done sleeping, exiting thread")
    }
}複製程式碼

但是自從 iOS 10 和 macOS Sierra 推出以後,所有平臺終於可以使用初始化指定執行閉包的方式建立執行緒,本文中所有的例子仍會擴充套件基礎的 Thread 類,這樣你就不用擔心為作業系統而做嘗試了。


var t = Thread {
    print("Started!")
}

t.stackSize = 1024 * 16
t.start()               //Time needed to spawn a thread around 100us複製程式碼

一旦我們有了一個執行緒例項,我們需要手動的啟動它。作為一個可選步驟,我們也可以為執行緒定義棧的大小。

執行緒可以通過呼叫 exit() 來緊急停止,但是我們從不推薦這麼做,因為它不會給你機會來乾淨利落地終止當前任務,如果你有需要,多數情況下你會選擇自己實現終止邏輯,或者只需要使用 cancel() 函式,然後檢查在主閉包中的 isCancelled 屬性,以明確執行緒是否需要在它自然結束之前終止當前的工作。

同步原語

當我們有多個執行緒想要修改共享資料時,就很有必要通過一些方式來處理這些執行緒之間的同步,防止資料破損和非確定性行為。

通常,用於同步執行緒的基本套路是鎖、訊號量和監視器。

這些 Foundation 都提供了。

正如你要看到的,在 Swift 3 中,這些沒有去掉 NS 字首的類(對,他們都是引用型別)實現了這些結構,但是在 Swift 接下來的某個版本中也許會去掉。

NSLock

NSLock 是 Foundation 提供的基本型別的鎖。

當一個執行緒嘗試鎖定一個物件時,可能會發生兩件事,如果鎖沒有被前面的執行緒獲取,那麼當前執行緒將得到鎖並執行,否則執行緒將會陷入等待,阻塞執行,直到鎖的持有者解鎖它。換句話說,在同一時間,鎖是一種只能被一個執行緒獲取(鎖定)的物件,這可以讓他們完美的監控對關鍵部分的訪問。

NSLock 和 Foundation 的其他鎖都是不公平的,意思是,當一系列執行緒在等待獲取一個鎖時,他們不會按照他們原來的鎖定順序來獲取它。

你無法預估執行順序。線上程爭奪的情況下,當多個執行緒嘗試獲取資源時,有的執行緒可能會陷入飢餓,他們永遠也不會獲得他們等待的鎖(或者不能及時的獲得)。

沒有競爭地獲取鎖所需要的時間,測量在 100 納秒以內。但是在多個執行緒嘗試獲取鎖定的資源時,這個時間會急速增長。所以,從效能的角度來講,鎖並不是處理資源分配的最佳方案。

讓我們來看一個例子,例中有兩個執行緒,記住由於鎖會被誰獲取的順序無法確定,T1 連續獲取兩次鎖的機會也會發生(但是不怎麼常見)。


let lock = NSLock()

class LThread : Thread {
    varid:Int = 0

    convenience init(id:Int){
        self.init()
        self.id = id
    }

    override func main(){
        lock.lock()
        print(String(id)+" acquired lock.")
        lock.unlock()
        iflock.try() {
            print(String(id)+" acquired lock again.")
            lock.unlock()
        }else{  // If already lockedmove along.
            print(String(id)+" couldn't acquire lock.")
        }
        print(String(id)+" exiting.")
    }
}

var t1 = LThread(id:1)
var t2 = LThread(id:2)
t1.start()
t2.start()複製程式碼

在你決定使用鎖之前,容我多說一句。由於你遲早會除錯併發問題,要把鎖的使用,限制在某種資料結構的範圍內,而不是在程式碼庫中的多個地方直接使用。

在除錯併發問題的同時,檢查有少量入口的同步資料結構的狀態,比跟蹤某個部分的程式碼處於鎖定,並且還要記住多個功能的本地狀態的方式更好。這會讓你的程式碼走的更遠並讓你的併發結構更優雅。

NSRecursiveLock

遞迴鎖能被已經持有鎖的執行緒多次獲取,在遞迴函式或者多次呼叫檢查相同鎖的函式時很有用處。不適用於基本的 NSLock。

let rlock = NSRecursiveLock()

classRThread : Thread {

    override func main(){
        rlock.lock()
        print("Thread acquired lock")
        callMe()
        rlock.unlock()
        print("Exiting main")
    }

    func callMe(){
        rlock.lock()
        print("Thread acquired lock")
        rlock.unlock()
        print("Exiting callMe")
    }
}

var tr = RThread()
tr.start()複製程式碼

NSConditionLock

條件鎖提供了可以獨立於彼此的附加鎖,用來支援更加複雜的鎖定設定(比如生產者-消費者的場景)。

一個全域性鎖(無論特定條件如何都鎖定)也是可用的,並且行為和經典的 NSLock 相似。

讓我們看一個保護共享整數鎖的簡單的例子,每次生產者更新而消費者列印都會在螢幕上顯示。

let NO_DATA = 1
let GOT_DATA = 2

let clock = NSConditionLock(condition: NO_DATA)
var SharedInt = 0

classProducerThread : Thread {

    override func main(){
        for i in 0..<5 {="" clock.lock(whencondition:="" no_data)="" acquire="" the="" lock="" when="" no_data="" if="" we="" don't="" have="" to="" wait="" for="" consumers="" could="" just="" done="" clock.lock()="" sharedint="i" clock.unlock(withcondition:="" got_data)="" unlock="" and="" set="" as="" got_data="" }="" classconsumerthread="" :="" thread="" override="" func="" main(){="" i="" in0..<5="" print(i)="" let="" pt="ProducerThread()" ct="ConsumerThread()" ct.start()="" pt.start()<="" code="">5>複製程式碼

當建立鎖的時候,我們需要指定一個由整數代表的初始條件。

lock(whenCondition:) 函式在條件符合時會獲得鎖,或者等待另一個執行緒用 unlock(withCondition:) 設定值來釋放鎖定。

對比基本鎖的一個小改進是,我們可以對更復雜的場景進行稍微建模。

NSCondition

不要與條件鎖產生混淆,一個條件提供了一種乾淨的方式來等待條件的發生。

當獲取了鎖的執行緒驗證它需要的附加條件(一些資源,處於特定狀態的另一個物件等等)不能滿足時,它需要一種方式被擱置,一旦滿足條件再繼續它的工作。

這可以通過連續性或週期性地檢查這種條件(繁忙等待)來實現,但是這麼做,執行緒持有的鎖會發生什麼?在我們等待的時候是保持還是釋放他們以至於在條件符合時重新獲取他們?

條件提供了一個乾淨的方式來解決這個問題,一旦獲取一個執行緒,就把它放進關於這個條件的一個等待列表中,它會在另一個執行緒發訊號時,表示條件滿足,而被喚醒。

讓我們看個例子:

let cond = NSCondition()
var available = false
var SharedString = ""
classWriterThread : Thread {

    override func main(){
        for _ in0..<5 5="" {="" cond.lock()="" sharedstring="?" available="true" cond.signal()="" notify="" and="" wake="" up="" the="" waiting="" thread="" s="" cond.unlock()="" }="" classprinterthread="" :="" override="" func="" main(){="" for="" _="" in0..<5="" just="" do="" it="" times="" while(!available){="" protect="" from="" spurious="" signals="" cond.wait()="" print(sharedstring)="" let="" writet="WriterThread()" printt="PrinterThread()" printt.start()="" writet.start()<="" code="">5>複製程式碼

NSDistributedLock

分散式鎖與之前我們所看到的截然不同,我不期望你經常需要它們。

它們由多個應用程式共享,並由檔案系統上的條目(如簡單檔案)支援。很明顯這個檔案系統能被所有想要獲取他(分散式鎖)的應用訪問。

這種鎖需要使用 try() 函式,一個非阻塞方法,它立即返回一個布林值,指出是否獲取鎖。獲取鎖通常需要多次的手動執行,並在連續嘗試之間適當延遲。

分散式鎖通常使用 unlock() 方法釋放。

讓我們看一個基本的例子:

var dlock = NSDistributedLock(path: "/tmp/MYAPP.lock")

iflet dlock = dlock {
    var acquired = falsewhile(!acquired){
        print("Trying to acquire the lock...")
        usleep(1000)
        acquired = dlock.try()
    }

    // Do something...

    dlock.unlock()
}複製程式碼

OSAtomic 你在哪裡?

OSAtomic 提供的原子操作是簡單的,並且允許設定、獲取或比較變數,而不需要經典的鎖邏輯,因為他們利用 CPU 的特定功能(有時是原生原子指令),並提供了比前面鎖所描述的更優越的效能。

對於建立併發資料結構來講,他們是非常有用的,因為處理併發所需的開銷被降低到最低。

OSAtomic 在 macOS 10.12 已經被捨棄使用,而在 Linux 上從來都不可以使用,但是一些開源的的專案,比如這個提供了實用的 Swift 擴充套件,或者這個提供了類似的功能。

同步塊

在 Swift 中你不能像在 Objective-C 中一樣,建立一個 @synchronized 塊,因為沒有等效的關鍵字可用。

在 Darwin 上,通過一些程式碼,你可以直接使用 objc_sync_enter(OBJ)objc_sync_exit(OBJ) 來弄出類似的東西,以進入現有的 @objc 物件監視器,就像 @synchronized 在底層所做的一樣,但這並不值得,如果你想要他們更靈活的話,最好是簡單地使用一個鎖。

就如我們將要描述排程佇列時看到的,用佇列,我們甚至可以使用更少的程式碼來執行同步呼叫來複制這個功能:

var count: Int {
    queue.sync {self.count}
}複製程式碼

本文或其他文章的 playground 可以在 GitHubZipped 找到。

GCD: 大中樞派發

對於不熟悉這個 API 的人來說,GCD 是一種基於佇列的 API,允許在工作池上執行閉包。

換句話說,包含需要執行的工作的閉包能被新增到一個佇列中,佇列會依賴於配置選項,順序或並行的用一系列執行緒來執行他們。但是無論佇列是什麼型別的,工作始終會按照先進先出的順序啟動,這意味著工作會始終遵循插入順序啟動。完成順序將依賴於每項工作的持續時間。

這是一種常見的模式,幾乎可以從每個處理併發的相對現代的語言執行時系統中找到。執行緒池的方式比一系列空閒和無關的執行緒更易於管理、檢查和控制。

GCD 的 API 在 Swift 3 中有一些小改動,SE-0088 模組化了它的設計,讓它看上去更物件導向了。

排程佇列

GCD 允許建立自定義的佇列,但是也提供了一些可以訪問的預定義系統佇列。

要建立一個順序執行你的閉包的基本序列佇列,你只需要提供一個字串標籤來標識它,通常建議使用反向域名字首,在堆疊追蹤的時候就能簡單地跟蹤佇列的所有者。

let serialQueue = DispatchQueue(label: "com.uraimo.Serial1")  //attributes: .serial

let concurrentQueue = DispatchQueue(label: "com.uraimo.Concurrent1", attributes: .concurrent)複製程式碼

我們建立的第二個佇列是併發的,意味著在執行工作時,佇列會使用底層執行緒池中的所有可用執行緒。這種情況下,執行順序是無法預測的,不要以為你的閉包完成的順序與插入順序有任何關係。

可以從 DispatchQueue 物件獲得預設佇列:

let mainQueue = DispatchQueue.main

let globalDefault = DispatchQueue.global()複製程式碼

main 佇列是 iOS 和 macOS 上處理圖形應用主事件迴圈的順序主佇列,用於響應事件和更新使用者介面。就如我們知道的,每個對使用者介面的改動都會在這個佇列執行,且這個執行緒中任何一個耗時操作都會使使用者介面的渲染變得不及時。

執行時系統也提供了對其他不同優先順序全域性佇列的訪問,可以通過 Quality of Service (Qos) 引數來檢視他們的標識。

不同優先順序宣告在 DispatchQoS 類裡,優先順序從高到低:

  • .userInteractive
  • .userInitiated
  • .default
  • .utility
  • .background
  • .unspecified

重要的是要注意,移動裝置提供了低電量模式,在電池較低時,後臺佇列會掛起

要取得一個特定的預設全域性佇列,使用 global(qos:) 根據想要的優先順序來獲取:

let backgroundQueue = DispatchQueue.global(qos: .background)複製程式碼

在建立自定義佇列時,也可以選擇使用與其他屬性相同的優先說明符:

let serialQueueHighPriority = DispatchQueue(label: "com.uraimo.SerialH", qos: .userInteractive)複製程式碼

使用佇列

包含任務的閉包可以以兩種方式提交給佇列:同步非同步,分別使用 syncasync 方法。

在使用前者時,sync 會被阻塞,換句話說,當它閉包完成(在你需要等待閉包完成時很有用,但是有更好的途徑)時呼叫的 sync 方法才會完成,而後者會把閉包新增到佇列,然後允許程式繼續執行。

讓我們看一個簡單的例子:


globalDefault.async {
    print("Async on MainQ, first?")
}

globalDefault.sync {
    print("Sync in MainQ, second?")
}複製程式碼

多個排程可以巢狀,例如在後臺完成一些東西、低優先、需要我們更新使用者介面的操作。


DispatchQueue.global(qos: .background).async {
    // Some background work here

    DispatchQueue.main.async {
        // It's time to update the UI
        print("UI updated on main queue")
    }
}複製程式碼

閉包也可以在一個特定的延遲之後執行,Swift 3 最終以一種更舒適的方式指定這個時間間隔,那就是使用 DispatchTimeInterval 工具列舉,它允許使用這四個時間單位組成間隔:.seconds(Int).milliseconds(Int).microseconds(Int).nanoseconds(Int)

要安排一個閉包在將來執行,使用 asyncAfter(deadline:execute:) 方法,並傳遞一個時間:

globalDefault.asyncAfter(deadline: .now() + .seconds(5)) {
    print("After 5 seconds")
}複製程式碼

如果你需要多次併發執行相同的閉包(就像你以前用 dispatch_apply 一樣),你可以使用 concurrentPerform(iterations:execute:) 方法,但請注意,如果在當前佇列的上下文中可能的話,這些閉包會併發執行,所以記得,始終應該在支援併發的佇列中同步或非同步地呼叫此方法。


globalDefault.sync {  
    DispatchQueue.concurrentPerform(iterations: 5) {
        print("\($0) times")
    }
}複製程式碼

雖然佇列在通常情況下,建立好就會準備執行它的閉包,但是它也可以配置為按需啟動。

let inactiveQueue = DispatchQueue(label: "com.uraimo.inactiveQueue", attributes: [.concurrent, .initiallyInactive])
inactiveQueue.async {
    print("Done!")
}

print("Not yet...")
inactiveQueue.activate()
print("Gone!")複製程式碼

這是我們第一次需要制定多個屬性,但就如你所見,如果需要,你可以用一個陣列新增多個屬性。

也可以使用繼承自 DispatchObject 的方法暫停或恢復執行的工作:

inactiveQueue.suspend()

inactiveQueue.resume()複製程式碼

僅用於配置非活動佇列(在活動的佇列中使用會造成崩潰)優先順序的方法 setTarget(queue:) 也是可用的。呼叫此方法的結果是將佇列的優先順序設定為與給定引數的佇列相同的優先順序。

屏障

讓我們假設你新增了一組閉包到特定的佇列(執行閉包的持續時間不同),但是現在你想只有當所有之前的非同步任務完成時再執行一個工作,你可以使用屏障來做這樣的事情。

讓我們新增五個任務(會睡眠 1 到 5 秒的時間)到我們前面建立的併發佇列中,一旦其他工作完成,就利用屏障來列印一些東西,我們在最後 async 的呼叫中規定一個 DispatchWorkItemFlags.barrier 標誌來做這件事。


globalDefault.sync { 
    DispatchQueue.concurrentPerform(iterations: 5) { (id:Int) in
        sleep(UInt32(id)+1)
        print("Async on globalDefault, 5 times: "+String(id))
    }
}   

globalDefault.async (flags: .barrier) {
    print("All 5 concurrent tasks completed")
}複製程式碼

單例和 Dispatch_once

就如你所知的一樣,在 Swift 3 中並沒有與 dispatch_once 等效的函式,它多數用來構建執行緒安全的單例。

幸運地,Swift 保證了全域性變數的初始化是原子性地,如果你認為常量在初始化後,他們的值不能發生改變,這兩個屬性使全域性常量成為實現單例的更容易的選擇。


final classSingleton {

    public static let sharedInstance: Singleton = Singleton()

    privateinit() { }

    ...
}複製程式碼

我們將類宣告為 final 以拒絕它子類化的能力,我們把它的指定構造器設為私有,這樣就不能手動建立它物件的例項。公共靜態變數是進入單例的唯一入口,它會用於獲取單例、共享例項。

相同的行為可以用於定義只執行一次的程式碼塊:

func runMe() {
    struct Inner {
        static let i: () = {
            print("Once!")
        }()
    }
    Inner.i
}

runMe()
runMe() // Constant already initialized
runMe() // Constant already initialized複製程式碼

雖然不太好看,但是它的確可以正常工作,而且如果只是執行一次,它也是可以接受的實現。

但是如果我們需要完全的複製 dispatch_once 的功能,我們就需要從頭實現它,就如同步塊中描述的一樣,利用一個擴充套件:


import Foundation

public extension DispatchQueue {

    private static var onceTokens = [Int]()
    private static var internalQueue = DispatchQueue(label: "dispatchqueue.once")

    public class func once(token: Int, closure: (Void)->Void) {
        internalQueue.sync {
            if onceTokens.contains(token) {
                return
            }else{
                onceTokens.append(token)
            }
            closure()
        }
    }
}

let t = 1
DispatchQueue.once(token: t) {
    print("only once!")
}
DispatchQueue.once(token: t) {
    print("Two times!?")
}
DispatchQueue.once(token: t) {
    print("Three times!!?")
}複製程式碼

和預期一致,三個閉包中,只有第一個會被實際執行。

或者,可以使用 objc_sync_enterobjc_sync_exit 來構建效能稍微好一點的東西,如果他們在你的平臺上可用的話:


import Foundation

public extension DispatchQueue {

    privatestatic var _onceTokens = [Int]()

    publicclass func once(token: Int, closure: (Void)->Void) {
        objc_sync_enter(self);
        defer { objc_sync_exit(self) }

        if _onceTokens.contains(token) {
            return
        }else{
            _onceTokens.append(token)
        }
        closure()
    }
}複製程式碼

Dispatch Groups

如果你有多個任務,雖然把他們新增到不同的佇列,也希望等待他們的任務完成,你可以把他們分到一個派發組中。

讓我們看一個例子,任務直接被新增到一個特定的組,用 syncasync 呼叫:

let mygroup = DispatchGroup()

for i in0..<5 {="" globaldefault.async(group:="" mygroup){="" sleep(uint32(i))="" print("group="" async on="" globaldefault:"+string(i))="" }="" }<="" code="">5>複製程式碼

任務在 globalDefault 上執行,但是我們可以註冊一個 mygroup 完成的處理程式,我們可以選擇在所有這些被完成後,執行這個佇列中的閉包。wait() 方法可以用於執行一個阻塞等待。

print("Waitingforcompletion...")
mygroup.notify(queue: globalDefault) {
    print("Notify received, done waiting.")
}
mygroup.wait()
print("Done waiting.")複製程式碼

另一種追蹤佇列任務的方式是,在佇列執行呼叫的時候,手動的進入和離開一個組,而不是直接指定它:


for i in 0..<5 {="" mygroup.enter()="" sleep(uint32(i))="" print("group="" sync="" on="" mainq:"+string(i))="" mygroup.leave()="" }<="" code="">5>複製程式碼

Dispatch Work Items

閉包不是指定作業需要由佇列執行的唯一方法,有時你可能需要一個能夠跟蹤其執行狀態的容器型別,為此,我們就有 DispatchWorkItem。每個接受閉包的方法都有一個工作項的變型。

工作項封裝一個由佇列的執行緒池呼叫 perform() 方法執行的閉包:

let workItem = DispatchWorkItem {
    print("Done!")
}

workItem.perform()複製程式碼

WorkItems 也提供其他很有用的方法,比如 notify,與組一樣,允許在一個指定的佇列完成時執行一個閉包

workItem.notify(queue: DispatchQueue.main) {
    print("Notify on Main Queue!")
}

defaultQueue.async(execute: workItem)複製程式碼

我們也可以等到閉包已經被執行或者在佇列嘗試執行它之前,使用 cancel() 方法(在閉包執行之間不會取消執行)把它標記為移除。

print("Waiting for work item...")
workItem.wait()
print("Done waiting.")

workItem.cancel()複製程式碼

但是,重要的是要知道,wait() 不僅僅會阻塞當前執行緒的完成,也會提升佇列中所有前面的工作專案的優先順序,以便於儘快的完成這個特定的專案。

Dispatch Semaphores

Dispatch Semaphores 是一種由多個執行緒獲取的鎖,它依賴於計數器的當前值。

執行緒在訊號量上 wait,直到那個每當訊號量被獲取時值都減小的計數器的值為 0

用於訪問訊號量,釋放等待執行緒的插槽名為 signal,它可以讓計數器的計數增加。

讓我們看一個簡單的例子:


let sem = DispatchSemaphore(value: 2)

// The semaphore will be held by groups of two pool threads
globalDefault.sync {
    DispatchQueue.concurrentPerform(iterations: 10) { (id:Int) in
        sem.wait(timeout: DispatchTime.distantFuture)
        sleep(1)
        print(String(id)+" acquired semaphore.")
        sem.signal()
    }
}複製程式碼

Dispatch Assertions

Swift 3 介紹了一種新的函式來執行當前上下文的斷言,可以校驗閉包是否在期望的佇列上執行。我們可以使用 DispatchPredicate 的三個列舉來構建謂詞:.onQueue,用來校驗在特定的佇列,.notOnQueue,來校驗相反的情況,以及 .onQueueAsBarrier,來校驗是否當前的閉包或工作項是佇列上的一個障礙。

dispatchPrecondition(condition: .notOnQueue(mainQueue))
dispatchPrecondition(condition: .onQueue(queue))複製程式碼

本文或其他文章的 playground 可以在 GitHubZipped 找到。

Dispatch Sources

Dispatch Sources 是處理系統級別非同步事件(比如核心訊號或系統,檔案套接字相關事件)的一種便捷方式。

有幾種可用的排程源,分組如下:

  • Timer Dispatch Sources: 用於在特定時間點或週期性事件中生成事件 (DispatchSourceTimer)。
  • Signal Dispatch Sources: 用於處理 UNIX 訊號 (DispatchSourceSignal)。
  • Memory Dispatch Sources: 用於註冊與記憶體使用狀態相關的通知 (DispatchSourceMemoryPressure)。
  • Descriptor Dispatch Sources: 用於註冊與檔案和套接字相關的不同事件 (DispatchSourceFileSystemObject, DispatchSourceRead, DispatchSourceWrite)。
  • Process dispatch sources: 用於監視與執行狀態有關的某些事件的外部程式 (DispatchSourceProcess)。
  • Mach related dispatch sources: 用於處理與Mach核心的 IPC 裝置有關的事件 (DispatchSourceMachReceive, DispatchSourceMachSend)。

如果有需要,你也可以構建你自己的排程源。所有排程源都符合 DispatchSourceProtocol 協議,它定義了註冊處理程式所需的基本操作,並修改了排程源的啟用狀態。

讓我們通過一個 DispatchSourceTimer 相關的例子,來理解如何使用這些物件。

源是由 DispatchSource 提供的工具方法建立的,在這我們會使用 makeTimerSource,指定我們想要執行處理程式的排程佇列。

Timer Sources 沒有其他的引數,所以我們只需要指定佇列,建立源,就如我們所見,能夠處理多個事件的排程源通常需要你指定要處理的事件的識別符號。


let t = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
t.setEventHandler{ print("!") }
t.scheduleOneshot(deadline: .now() + .seconds(5), leeway: .nanoseconds(0))
t.activate()複製程式碼

一旦源被建立,我們就會使用 setEventHandler(closure:) 註冊一個事件處理程式,如果不需要其他配置,就可以通過 activate() 讓源可用。

排程源初始化不具備活性,意味著如果沒有進一步的配置,他們不會開始傳遞事件。一旦我們準備就緒,源就能通過 activate() 啟用,如果有需要,可以通過 suspend()resume() 來暫時掛起和恢復事件傳遞。

Timer Sources 需要一個額外的步驟來配置物件需要傳遞的是哪一種型別的定時事件。在上面的例子中,我們定義了單一的事件,會在註冊後 5 秒嚴格執行。

我們也可以配置物件來傳遞週期性事件,就像我們使用 Timer 物件那樣:

t.scheduleRepeating(deadline: .now(), interval: .seconds(5), leeway: .seconds(1))複製程式碼

當我們完成了排程源的使用,並想要完全停止事件的傳遞時,我們可以呼叫 cancel(),它會停止事件源,呼叫消除相關的處理程式(如果我們已經設定了一個處理一些結束後的清理操作,比如登出)。

t.cancel()複製程式碼

對於其他型別的排程源來說 API 都是相似的,讓我們看一個關於 Kitura 初始化讀取源的例子,它用於在已建立的套接字上進行非同步讀取:


readerSource = DispatchSource.makeReadSource(fileDescriptor: socket.socketfd,
                                             queue: socketReaderQueue(fd: socket.socketfd))

readerSource.setEventHandler() {
    _ = self.handleRead()
}
readerSource.setCancelHandler(handler: self.handleCancel)
readerSource.resume()複製程式碼

當套接字的資料緩衝區有新的位元組可以傳入的時候,handleRead() 方法會被呼叫。Kitura 也使用 WriteSource 執行緩衝寫入,使用排程源事件有效地調整寫入速度,一旦套接字通道準備好傳送就寫入新的位元組。在執行 I/O 操作的時候,對比於 Unix 平臺上的其他低階 API,讀/寫源是一個很好的高階替代。

與檔案相關的排程源的主題,另一個在某些情況中可能有用的是 DispatchSourceFileSystemObject,它允許監聽特定檔案的更改,從其名稱到其屬性。通過此排程源,在檔案被修改或刪除時,你也會收到通知。Linux 上的事件子集實質上都是由 inotify 核心子系統管理的。

剩餘型別的源操作大同小異,你可以從 libDispatch 的文件中檢視完整的列表,但是記住他們其中的一些,比如 Mach 源和記憶體壓力源只會在 Darwin 的平臺工作。

操作與可操作的佇列

我們簡要的介紹一下 Operation Queues,以及建立在 GCD 之上的附加 API。它們使用併發佇列和模型任務作為操作,這樣做可以輕鬆的取消操作,而且能讓他們的執行依賴於其他操作的完成。

操作能定義一個執行順序的優先順序,被新增到 OperationQueues裡非同步執行。

我們看一個基礎的例子:


var queue = OperationQueue()
queue.name = "My Custom Queue"queue.maxConcurrentOperationCount = 2

var mainqueue = OperationQueue.main //Refers to the queue of the main threadqueue.addOperation{
    print("Op1")
}
queue.addOperation{
    print("Op2")
}複製程式碼

我們也可以建立一個阻塞操作物件,然後在加入佇列之前配置它,如有需要,我們也可以向這種操作新增多個閉包。

要注意的是,在 Swift 中不允許 NSInvocationOperation 使用目標+選擇器建立操作。

var op3 = BlockOperation(block: {
    print("Op3")
})
op3.queuePriority = .veryHigh
op3.completionBlock = {
    if op3.isCancelled {
        print("Someone cancelled me.")
    }
    print("Completed Op3")
}

var op4 = BlockOperation {
    print("Op4 always after Op3")
    OperationQueue.main.addOperation{
        print("I'm on main queue!")
    }
}複製程式碼

操作可以有主次優先順序,一旦主優先順序完成,次優先順序才會執行。

我們可以從 op4 新增一個依賴關係到 op3,這樣 op4 會等待 op3 的完成再執行。


op4.addDependency(op3)

queue.addOperation(op4)  // op3 will complete before op4, alwaysqueue.addOperation(op3)複製程式碼

依賴也可以通過 removeDependency(operation:) 移除,被儲存到一個公共可訪問的 dependencies 陣列裡。

當前操作的狀態可以通過特定的屬性檢視:


op3.isReady       //Ready for execution?
op3.isExecuting   //Executing now?
op3.isFinished    //Finished naturally or cancelled?
op3.isCancelled    //Manually cancelled?複製程式碼

你可以呼叫 cancelAllOperations 方法,取消佇列中所有的當前操作,這個方法會設定佇列中剩餘操作的 isCancelled 屬性。一個單獨的操作可以通過呼叫它的 cancel 方法來取消:

queue.cancelAllOperations() 

op3.cancel()複製程式碼

如果在計劃執行佇列之後取消操作,建議您檢查操作中的 isCancelled 屬性,跳過執行。

最後要說是,你也可以停止操作佇列上執行新的操作(正在執行的操作不會受到影響):

queue.isSuspended = true複製程式碼

本文或其他文章的 playground 可以在 GitHubZipped 找到。

閉幕後的思考

本文可以說是從 Swift 可用的外部併發框架的視角,給出一個很好的總結。

第二部分將重點介紹下一步可能在語言中出現的處理併發的“原生”功能,而不需要藉助外部庫。通過目前的一些開源實現來講述幾個有意思的範例。

我希望這兩篇文章能夠對併發世界做一個很好的介紹,並且將幫助你瞭解和參與在急速發展的郵件列表中的討論,在社群開始考慮將要介紹的內容時,我們一起期待 Swift 5 的到來。

關於併發和 Swift 的更多有趣內容,請看 Cocoa With Love 的部落格。

你喜歡這篇文章嗎?讓我在推特上看到你!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章