譯:用Swift並行編程(基礎篇)

大約1年前,個人團隊開始了一個新的項目。此次咱們想使用咱們以前項目的全部知識。其中一個決定就是:咱們想將整個 model API 異步化。這將容許咱們在不影響 APP 其他部分的狀況下,改變整個 model 的實現。若是咱們的 APP 能夠去處理異步調用,那麼咱們就不須要關心是否與後端通訊、是否緩存數據到數據庫了(譯者注:由於是異步調用,因此咱們不用擔憂網絡加載、緩存到數據庫的操做阻塞了主線程)。它也能使咱們實現併發。html

做爲開發者,咱們必須去理解並行(parallelism)和併發(concurrency)的含義。不然,咱們可能會犯一些很嚴重的錯誤。如今,讓咱們一塊兒學習如何併發編程吧!git

同步 vs 異步

那麼,同步和異步到底有什麼不一樣之處呢?假設咱們有一堆item,當對這些item進行同步處理的時候,咱們先從第一個item開始,而後再完成第二個,以此類推。它的執行順序和FIFO( First In,First Out )的隊列是同樣的,先進先出。github

轉換爲代碼:method1()的每一個語句按順序執行。數據庫

// 執行順序 statement1() -> statement2() -> statement3() -> statement4()
func method1() {
    statement1()
    statement2()
    statement3()
    statement4()
}
複製代碼

因此,同步意味着:一次只能完成一個item。編程

相比之下,異步處理能夠在同時處理多個item。例如:它會處理item1,而後暫停item1,處理item2,而後繼續並完成item1。swift

用一個簡單的callback來舉個栗子,咱們能夠看到statement2會在執行callback以前執行。後端

func method2() {
    statement1 {
        callback1()
    }
    statement2
}

//譯者注:如咱們經常使用的URLSession
func requestData() {
    URLSession.shared.dataTask(with: URL(string: "https://www.example.com/")!) { (data, response, error) in
        DispatchQueue.main.async {
            print("callback")
        }
    }.resume()
    
    print("statement2")
}

requestData()
//打印順序 statement2 callback
複製代碼

併發與並行 ( Concurrency vs Parallelism )

併發與並行一般能夠互換使用(即便Wiki百科也有用錯的地方。。),這就致使了一些問題,若是咱們能清晰的知道二者的含義的話,咱們就能夠避免這些問題。讓咱們舉例說明:數組

試想一下:咱們有一堆盒子,須要從A點運送到B點。咱們可使用工人完成這項工做。在同步環境下,咱們只能用1個工人去作這件事情,他從A點拿起一個盒子,到B點放下盒子。緩存

若是咱們要是同時能僱傭多個工人,他們將同時工做,從A點拿起一個盒子,到B點放下盒子。很明顯,這將極大的提高咱們的效率。只要至少2位工人同時運輸盒子,他們就是在作並行處理。bash

並行就是同時進行工做。

若是咱們只有一個工人,咱們想讓他多作幾件事,那麼發生什麼呢?那麼咱們就應該考慮在處理狀態下有多個盒子了。這就是併發的含義,它就比如把從 A 點到 B 點的距離分割爲幾步,工人能夠從 A 點拿一個盒子,走到一半放下盒子,再回到 A 點去拿另外一個盒子。

使用多個工人咱們可讓他們都帶有不一樣距離的盒子。這樣咱們能夠異步處理這些盒子。若是咱們有多個工人那麼咱們能夠並行處理這些盒子。

如今,並行和併發的區別就比較明瞭了。並行指的是同時進行工做;併發指的是同時工做的選擇,它可使並行,也能夠不是。咱們大多數的電腦和手機設備能夠進行並行處理(取決於它是幾核的),可是軟件確定是併發工做的。

併發機制

不一樣的操做系統提供不一樣的工具供你使用併發。在iOS,咱們默認的工具: 進程和線程,因爲OC的歷史緣由,也有 Dispatch Queues。

進程( Process )

進程是你 app 的實例。它包含執行你 App 所需的全部的東西,具體包含:你的棧、堆和全部的資源。

儘管 iOS 是一個多任務的 OS ,可是它不支持一個 App 使用多個進程,所以你只有一個進程。可是 MAC OS 不一樣,你可使用 Process 類去建立新的子進程。 它們與父進程無關,但包含父進程建立子進程時父進程所擁有的全部信息。若是您正在使用macOS,這裏是建立和執行進程的代碼:

let task = Process()
task.launchPath = "/bin/sh" //executable you want to run
task.arguments = arguments //here is the information you want to pass
task.terminationHandler = {
  // do here something in case the process terminates
}

task.launch()
複製代碼

線程 ( Thread )

thread 相似於輕量級的進程。相比於進程 ,線程在它們的父進程中共享它們的內存。這樣就會致使一些問題,好比兩個線程同時改變一個變量。當咱們再次讀取改變量的值得時候,咱們會獲得沒法預知的值。在 iOS (或者其餘符合 POSIX 的系統)中,線程是被限制的資源,一個進程同時最多有用64個線程。你能夠像這樣建立並執行線程:

class CustomThread: Thread {
  override func main() {
    do_something
  }
}

let customThread = CustomThread()
customThread.start()
複製代碼

Dispatch Queues

因爲咱們只有一個進程而且最多隻能使用64個線程,因此必須使用其餘的方法去使代碼進行併發處理。 Apple 的解決方案就是 dispatch queue 。你能夠向 dispatch queue 中添加任務,而後期待在某一時刻被執行。 dispatch queue 有不一樣的類型:

  • SerialQueue:串行隊列,它會順序執行該隊列的任務。
  • ConcurrentQueue:併發隊列,它會併發執行該隊列的任務。

這不是真正的併發,對吧?尤爲是串行隊列,咱們的效率並無任何的提升。併發隊列也沒有使任何事情變得容易。咱們確實擁有線程,因此重點是什麼?

讓咱們考慮一下,若是咱們有多個隊列會發生什麼呢。咱們能夠在線程上運行多個隊列,而後在咱們須要的時候向其中一個隊列添加任務。讓咱們開一下腦洞,咱們甚至能夠根據優先級和當前工做量來分發須要添加的任務,從而優化咱們的系統資源。

Apple 把上述的實現稱爲 Grand Central Dispatch ,簡稱 GCD 。在 iOS 它具體是如何操做呢?

DispatchQueue.main.async {
    // execute async on main thread
}
複製代碼

GCD 最大的優勢就是:它改變了併發編程的思惟模型。你使用它的時候不須要考慮 thread ,你只須要把你須要執行的任務添加到不一樣的隊列中,這使併發編程變得更加容易。

Operation Queues

Operation Queue 是 Cocoa 對 GCD 的更高一級的抽象。你能夠建立 operation 而不是一些 block 塊。它將把 operation 添加到隊列中,而後按照正確的順序執行它們。關於隊列分別有如下類型:

  • main queue:主隊列,在主線程執行。
  • custom queue:自定義隊列,不在主線程執行。
let operationQueue: OperationQueue = OperationQueue()
operationQueue.addOperations([operation1], waitUntilFinished: false)
複製代碼

你能夠經過 block 或者子類的方式來建立 operation 。若是你使用子類的方式建立,不要忘記調用 finish ,若是忘記調用,則 operation 將會一直執行。

class CustomOperation: Operation {
    override func main() {
        guard isCancelled == false else {
            finish(true)
            return
        }
        
        // Do something
        
        finish(true)
    }
}
複製代碼

operation 的優點就是你可使用依賴,若是 A 依賴於 B 的結果,那麼在獲得 B 的結果以前, A 不會被執行。

//execute operation1 before operation2
operation2.addDependency(operation1) 
複製代碼

Run Loops

Run Loop 跟隊列相似。系統隊列運行全部的工做,而後在開始的時候重啓,例如:屏幕重繪,經過 Run Loop 完成。這裏咱們須要注意一點,它們不是真正的併發方法,它們是在一個線程上運行的。它可使你的代碼異步執行,同時減去你考慮併發的負擔。不是每一個線程都有 Run Loop ,主線程的 Run Loop 是默認開啓的,子線程的 Run Loop 須要手動建立。

當你使用 Run Loop 的時候,你須要考慮在不一樣 mode 下的狀況。舉個栗子,當你滑動你的設備的時候,主線程的 Run Loop 會改變並延時全部進入的事件,當你中止滑動的時候, Run Loop 將會切換爲默認的 mode ,而後處理事件。input source 對 Run Loop 來講是必要的,不然,每一個執行操做都會馬上結束。因此不要忘了這個點。

Lightweight Routines

關於真正輕量級的線程有一個新的想法,可是它尚未被 Swift 實現。詳情能夠點擊這裏

控制併發的選項 (Options to control Concurrency)

咱們研究了由操做系統提供的全部不一樣的元素,這些元素能夠建立併發。可是如上所述,這也會形成不少問題。最容易碰到的同時也是最難識別的問題就是:多個併發任務同時訪問同一資源。若是沒有機制去處理這些訪問,則可能一個任務寫入一個值。當第一個任務讀取這個值的時候,它期待的是本身寫入的那個值,而不是其餘任務寫入的值。因此,默認的方法是鎖住資源的訪問來阻止其餘線程在資源鎖定的時候來訪問它。

優先級反轉 (Priority Inversion)

在瞭解各類鎖機制之間的不一樣之處以前,咱們須要先了解一下線程優先級。正如你所想的,線程能夠設置高優先級和低優先級,這意味着高優先級的會比低優先級的先執行。當一個低優先級的線程鎖住一個資源的時候,若是一個高優先級的線程來訪問該資源,高優先級的線程必須等解鎖,這樣低優先級的線程的優先級就會增長。這就叫作優先級反轉,但這會致使高優先級的線程一直等待,由於它永遠不會被執行。因此咱們須要注意避免形成這種狀況。

想象一下,你如今有兩個高優先級的線程一、線程2和一個低優先級的線程3。若是線程3阻塞線程1訪問資源,線程1必須去等待。由於線程2有更高的優先級,它的任務會被先執行完。在沒有結束的狀況下,線程3將不會被執行,所以線程1將被無限期地阻塞。

優先級繼承

優先級反轉的解決方案是優先級繼承。在這種狀況下,若是線程1被線程3阻塞,它會將本身的優先級交給線程3。因此線程3和線程2都有高優先級,能夠一塊兒執行(依賴於OS)。當線程3解鎖資源的時候,再將優先級還給線程1,這樣線程1將會繼續原來的操做。

原子性 (Atomic)

原子性包含和數據庫上下文中的事務相同的思想。你想一次性寫入一個值,做爲一個操做。32位編譯的應用程序,在使用int64_t而沒有原子性時,可能會有奇怪的行爲。讓咱們詳細看看發生了什麼:

int64_t x = 0
Thread1:
x = 0xFFFF
Thread2:
x = 0xEEDD
複製代碼

非原子性的操做會形成在線程1中寫入x,可是由於咱們在32位系統上工做,咱們不得不將寫入的x分割成 0xFF。

當線程2決定在同一時間寫入x的時候,會發生下面的操做進行:

Thread1: part1
Thread2: part1
Thread2: part2
Thread1: part2
複製代碼

最後咱們會獲得:

x == 0xEEFF
複製代碼

既不是 0xFFFF 也不是 0xEEDD。

使用原子性,咱們建立一個單獨的事務,會產生如下的行爲:

Thread1: part1
Thread1: part2
Thread2: part1
Thread2: part2
複製代碼

結果是,x包含線程2設置的值。 Swift 自己沒有實現 atomic 。你能夠在這裏添加一個建議,可是如今,你必須本身實現它。

鎖是一種簡單的方法,用來阻止多個線程訪問同一資源。首先檢查線程是否能夠進入被保護的部分,若是能夠進入,它將鎖住被保護的資源,而後進行該線程操做。等線程的操做執行完,它會解鎖該資源。若是進入的線程碰到鎖住的部分,它會等待解鎖。這有點相似於睡眠和喚醒,以檢查資源是否被鎖。

在 iOS ,能夠經過 NSLock 來實現這種機制。須要注意一點:你解鎖的線程和你鎖住的線程必須是同一線程。

let lock = NSLock()
lock.lock()
//do something
lock.unlock()
複製代碼

還有其餘類型的鎖,好比遞歸鎖 (recursive lock) 。它能夠屢次鎖住同一資源,而且必須在鎖定的時候釋放它。在這整個過程當中,其它線程是被排除在外的。

還有一個就是讀寫鎖 (read-write lock),對於須要大量線程讀取,而不須要大量線程寫入的大型 App ,這是頗有效的一種機制。只要沒有線程寫入,則全部線程均可訪問。只要有線程想寫入,它將鎖定全部線程的資源。在解鎖以前全部線程都不能讀取。

在進程級別,還有一個分佈式鎖 (distributed lock) 。不一樣之處在於,若是進程被阻止,它只會將其報告給進程,而且進程能夠決定如何處理這種狀況。

自旋鎖( Spinlock )

鎖由多個操做組成,這些操做使線程處於休眠狀態,直到線程再次啓動爲止。這會致使 CPU 的上下文更改 (推送註冊等等,去存儲線程的狀態)。這些改變須要不少計算時間,若是你有真正很小型的操做去保護,你可使用自旋鎖。它的基本思想就是隻要線程在等待,就讓它輪詢鎖( poll the lock )。這比休眠一個線程須要更多的資源。同時,它繞過了上線文的改變,因此在小型操做上更加快。

這個理論上聽着不錯,可是 iOS 老是出人意料。 iOS 有一個概念叫作 Quality of Service (QoS)。使用它,可能形成低優先級的線程根本不會執行的狀況。在這樣的線程上設置一個自旋鎖,當一個更高優先級的線程試圖訪問它的時候,會形成高優先級的線程覆蓋低優先級的線程,所以,沒法解鎖所需資源,從而致使阻塞本身。因此,自旋鎖在 iOS 是非法的。

互斥 (Mutex)

互斥跟鎖比較像,不一樣之處在於,它能夠訪問進程而不只僅是訪問線程。悲催的是你不得不本身實現它,Swift 不支持互斥。你可使用 C 的pthread_mutex

var m = pthread_mutex_t()
pthread_mutex_lock(&m)
// do something
pthread_mutex_unlock(&m)
複製代碼

信號量 (Semaphore)

信號量是一種支持線程同步中的互斥性的一種數據結構。它由計數器組成,是一個先進先出的隊列,有wait()signal()函數。

每當線程想要進入一個被保護部分的時候,它將會調用信號量的wait()。信號量將會減小它的計數,只要計數不爲0,線程就能夠繼續。反之,它會將線程存儲在它的隊列裏。當被保護部分離開一個線程的時候,它會調用signal()來通知信號量。信號量首先會檢查,是否在隊列中有等待的線程,若是有,它將喚醒線程,讓它繼續。若是沒有,它將會再次增長它的計數。

在 iOS 中,咱們可使用 DispatchSemaphors 來實現這種行爲。比起默認信號量,它更傾向於使用 DispatchSemaphors ,由於它們只在真正須要時纔會降低到內核級別。不然,它的運行速度會快得多。

let s = DispatchSemaphore(value: 1)
_ = s.wait(timeout: DispatchTime.distantFuture)
// do something
s.signal()
複製代碼

有人認爲二進制的信號量(計數爲1的信號量)和互斥是同樣的。但互斥是一種鎖的機制,信號量是一種信號的機制。這個解釋並無什麼幫助,因此它們到底有什麼不一樣呢?

鎖機制是關於保護和管理一個資源的訪問,因此它會阻止多個線程同時訪問一個資源。信號系統更像是"Hey 我完事了,繼續!"。舉個栗子:若是你拿你的手機正在聽歌,這時候來了一個電話。當你通完電話,它將會給你的 player 發送一個通知讓它繼續。這是一個在互斥上考慮信號量的狀況。

譯者注:我猜想,放歌和聽音樂是互斥的,由於你不可能接電話的時候還聽着歌。在通話完成後,手機給player發送一個信號讓它繼續放歌,這是一個信號量的操做。

假如你有一個低優先級的線程1在受保護的區域,你還有一個高優先級的線程2被信號量調用wait()使其等待。此時線程2處於休眠狀態等待信號量將其喚醒。此時,咱們有一個線程3,優先級高於線程1。線程3會聯合 Qos 來阻止線程1去通知信號量,所以而覆蓋其餘線程。因此 iOS 中的信號量並無優先級繼承。

同步 ( Synchronized )

在 OC 中,有一個@synchronized的關鍵字。這是建立互斥的簡單方法。因爲 Swift 不支持,咱們不得不用更底層的方法:objc_sync_enter

let lock = self

objc_sync_enter(lock)
closure()
objc_sync_exit(lock)
複製代碼

由於我在網上看到這個問題不少次,因此讓咱們回答一下。據我所知,這不是一個私有方法,因此使用它不會被 App Store 拒審。

併發隊列派發 ( Concurrency Queues Dispatching )

因爲 Swift 中沒有 metux ,並且 synchornized 也被移除,因此使用 DispatchQueues 成了 Swift 開發者的黃金法則。當用它實現同步的時候,它與 metux 有相同的行爲。由於全部操做都在同一隊列中排隊。這能夠防止同時執行。

它的缺點是它的時間消耗大,它必須常常分配和改變上下文。若是你的 App 不須要任何高計算能力,這就可有可無了。可是若是遇到幀丟失等問題,你可能就須要考慮別的方案了(例如 Mutex)。

Dispatch Barriers

若是你使用 GCD,你有不少辦法來同步代碼。其中一個就是 Dispatch Barriers。經過它,咱們能夠建立須要一塊兒執行的被保護部分的 block 。咱們也能夠異步執行這些代碼,這聽起來很奇怪,可是假想一下,你有一個耗時的操做,它能夠被分割爲幾個小任務。這些小任務能夠被異步執行,當小任務都執行完,Dispatch Barriers 在去同步這些小任務。

譯者注:好比將一張大圖分割爲幾張小圖異步下載,等小圖都異步下載完,再同步爲一張大圖。

Trampoline

它並非操做系統提供的一種機制。它是一種模式:用來確保方法在正確的線程被調用。它的思路很簡單,在開始檢查方法是否在正確的線程上,若是不在,它會在正確的線程上調用它本身並返回。有時,你須要使用上面鎖的機制來實現等待程序。只有在調用方法有返回值的時候纔會發生這種狀況。不然,你能夠簡單的返回。

func executeOnMain() {
  if !Thread.isMainThread {
    DispatchQueue.main.async(execute: {() -> Void in
      executeOnMain()
      return
    })
  }
  // do something
}
複製代碼

不要常用這種模式。雖然它能夠確保你在正確的線程,但同時,它會使你的同事困惑。他們可能不理解你處處改變線程。某些時候,它會使你的代碼 like shit,而且浪費你的時間去整理代碼。

總結

Wow,這真是一篇工做量很大的文章。這裏有如此多的技術能夠實現併發編程,這篇文章只是淺嘗輒止。當我在討論併發的時候,你們都厭煩我,可是併發編程真的很重要,同事們也在慢慢的承認我。今天,我不得不修復一個數組異步的問題,咱們知道 Swift 不支持原子性操做。你猜怎麼着?這形成了一個崩潰。若是咱們更加了解併發,可能就不會出現這個問題。但說實話,我以前也不知道這個。

我能給你最好的建議就是:知己知彼,百戰不殆。綜合上文所述,我但願你能夠開始學習併發,能找到一種方法用來解決遇到的問題。一旦你更加深刻,你就會愈來愈明瞭。Good Luck!

相關文章
相關標籤/搜索