歡迎光臨
我們一直在努力

[譯] Swift 併發程式設計的 10 大陷阱

文章摘要: x = 0xEEDD 第一個執行緒開始往 x 寫入值print(x) 根據這些執行緒執行的時間點

作者|Jan Olbrich

譯者|無明

編輯|覃雲

在使用 Swift 進行併發程式設計時,作業系統提供了一些底層的基本操作。例如,蘋果為此提供了框架或其他東西,比如已經在 JavaScript 中廣泛使用的 promise。這篇文章將對 Swift 的併發程式設計做更加全面的介紹,並告訴大家,如果不瞭解併發,有可能會犯下哪些錯誤。

原子性

Swift 中的原子性與資料庫中的事務具有相同的概念,即一次性寫入一個值被視為一個操作。在將應用程式編譯為 32 位時,如果沒有使用原子性,並在程式碼中使用了 int64_t,那麼可能會出現相當奇怪的行為。為什麼?讓我們來詳細瞭解下:

int64_t x = 0
Thread1:
x = 0xFFFF
Thread2:
x = 0xEEDD

第一個執行緒開始往 x 寫入值,但由於應用程式需要執行在 32 位作業系統上,我們必須將要寫入 x 的值分成兩批 0xFF。

當 Thread2 嘗試同時寫入 x 時,可能會按以下順序執行:

Thread1: part1
Thread2: part1
Thread2: part2
Thread1: part2

最後我們會得到:

x == 0xEEFF

既不是 0xFFFF 也不是 0xEEDD。

如果使用原子性,我們就建立了一個單獨的事務,於是就變成:

Thread1: part1
Thread1: part2
Thread2: part1
Thread2: part2

結果,x 包含 Thread2 設定的值。Swift 本身沒有提供原子性實現,不過已經有建議要在 Swift 中新增原子性,但目前,你必須自己實現它。

最近,我修復了一個 bug,這個 bug 是由兩個不同執行緒同時向一個數組寫入引起的。如果同一組中的兩個操作可以並行執行並且同時失敗,會發生什麼?它們將嘗試同時向錯誤陣列寫入,這將導致 Swift.Array 的「allocate capacity」錯誤。要修復這個問題,陣列必須是執行緒安全的,可以使用同步陣列。

一般情況下,在每次寫入時必須進行加鎖。

但需要注意的是,讀取也可能失敗:

var messages: [Message] = []

func dispatch(_ message: Message) {
  messages.append(message)
  dispatchToPlugins()
}

func dispatchToPlugins() {
  while messages.count > 0 {
    for plugin in plugins {
      plugin.dispatch(message: messages[0]) 
    }
    messages.remove(at:0)
  }
}

Thread1:
dispatch(message1)

Thread2:
dispatch(message2)

我們迴圈遍歷一個數組,只要陣列長度不為 0,就將陣列中的元素分派給外掛,然後從陣列中移除。這種方式非常容易導致「index out of range」異常。

記憶體屏障

現在的 CPU 有多個核心,幷包含了智慧編譯器,我們無法預測程式碼會執行在哪個核心上。硬體甚至會優化我們的記憶體操作。簿記(bookkeeping)可確保它們在同一個核心上是按照一定的順序執行的。遺憾的是,這仍然可能導致一個核心會看到不同順序的記憶體變更。看看這個簡單的例子:

//Processor #1:
while f == 0 {
  print x
}

//Processor #2:
x = 42
f = 1

你可能希望這段程式碼會列印出 42,因為 x 是在 f 被設定為 false 之前賦值的。不過有時可能發生這種情況,即第二個 CPU 以相反的順序看到記憶體的變更,因此會先結束迴圈,列印 x 的值,然後纔看到新值 42。

我還沒有在 iOS 上看到過這種情況,但這並不意味著它不會發生。特別隨著 CPU 核心數量越來越多,對這種底層硬體陷阱的認識至關重要。

那麼該如何解決這個問題?Apple 為此提供了記憶體屏障。它們是一組命令,用於確保在執行下一個記憶體操作之前完成當前的操作。這將阻止 CPU 優化我們的程式碼,導致執行時間變慢一些。但你沒有必要太注意這點效能差異,除非你是在構建高效能的系統。

記憶體屏障使用起來很簡單,但要注意,它是一個作業系統函式,不屬於 Swift。因此 API 是使用 C 語言實現的。

OSMemoryBarrier() // from 

在上面的程式碼中使用記憶體屏障:

//Processor #1:
while f == 0 {
  OSMemoryBarrier()
  print x
}

//Processor #2:
x = 42
OSMemoryBarrier()
f = 1

這樣,我們所有的記憶體操作都將按順序進行,不必擔心硬體記憶體重新排序會產生不必要的副作用。

竟態條件

發生競態條件時,多個執行緒的行為取決於單個執行緒的執行時行為。假設有兩個執行緒,一個執行計算並將結果儲存在 x 中,另一個(可能來自不同的執行緒,比如使用者互動執行緒)將結果列印到螢幕上:

var x = 100

func calculate() {
    var y = 0
    for i in 1...1000 {
        y += i
    }

    x = y
}

calculate()

print(x)

根據這些執行緒執行的時間點,Thread2 有可能不會將計算結果列印到螢幕上,它可能還持有之前的值,而這樣的行為是非預期的。

還有另外一種情況,即兩個執行緒向同一個陣列寫入。假設第一個執行緒將「Concurrency with Swift:」中的單詞寫入陣列,另一個執行緒寫入「What could possibly go wrong?」。我們可以這樣實現:

func write(_ text: String) {
    let words = text.split(separator: " ")
    for word in words {
        title.append(String(word))
    }
}

write("Concurrency with Swift:") // Thread 1
write("What could possibly go wrong?") // Thread 2

我們可能會得到錯亂的標題:

「Concurrency with What could possibly Swift: go wrong?」

這不是我們所期望的那樣,不是嗎?不過我們有很多種方法可以解決這個問題:

var title : [String] = []
var lock = NSLock()

func write(_ text: String) {
    let words = text.split(separator: " ")
    lock.lock()
    for word in words {
        title.append(String(word))
        print(word)
    }
    lock.unlock()

另一種方法是使用 Dispatch Queue:

var title : [String] = []

func write(_ text: String) {
    let words = text.split(separator: " ")
    DispatchQueue.main.async {
        for word in words {
            title.append(String(word))
            print(word)
        }
    }

可以根據你的需求選擇其中的一種。一般來說,我傾向於使用 Dispatch Queue。這種方法可以防止出現死鎖等問題,我們將在下面詳細介紹。

死鎖

我們可以使用多種方法來解決竟態條件問題,但如果我們使用了 Lock、Mutexe 或 Semaphore,將會引入另一個問題:死鎖。

死鎖是由環狀等待引起的。一個執行緒在等待第二個執行緒持有的資源,第二個執行緒也在等待第一個執行緒持有的資源。

舉個簡單的例子,在一個銀行賬戶上執行一個事務,這個事務分為兩個部分:先取款,後存款。

程式碼看起來像這樣:

class Account: NSObject {
    var balance: Double
    var id: Int

    override init(id: Int, balance: Double) {
        self.id = id
        self.balance = balance
    }

    func withdraw(amount: Double) {
        balance -= amount
    }

    func deposit(amount: Double) {
        balance += amount
    }
}

let a = Account(id: 1, balance: 1000)
let b = Account(id: 2, balance: 300)

DispatchQueue.global(qos: .background).async {
    transfer(from: a, to: b, amount: 200)
}

DispatchQueue.global(qos: .background).async {
    transfer(from: b, to: a, amount: 200)
}

func transfer(from: Account, to: Account, amount: Double) {
    from.synchronized(lockObj: self) { () -> T in
        to.synchronized(lockObj: self) { () -> T in
            from.withdraw(amount: amount)
            to.deposit(amount: amount)
        }
    }
}

extension NSObject {
    func synchronized(lockObj: AnyObject!, closure: () throws -> T) rethrows ->  T
    {
        objc_sync_enter(lockObj)
        defer {
            objc_sync_exit(lockObj)
        }

        return try closure()
    }
}

我們在事務之間引入了依賴關係,這將導致死鎖。

另一個死鎖問題是哲學家就餐問題。在維基百科上是這麼描述的:

「五位沉默的哲學家坐在圓桌旁,桌上放著一碗意大利麪。叉子放置在每對相鄰的哲學家之間。

每位哲學家都必須在思考和吃飯之間交替。不過,哲學家只有在左手邊和右手邊的叉子同時可用時才能吃意大利麪。每個叉子同時只能由一位哲學家持有,因此只有當沒有其他哲學家在使用它時,其中的一位哲學家才能使用它。一位哲學家在吃完之後,需要放下兩把叉子,以便讓其他哲學家使用叉子。哲學家可以拿起他右手邊或左手邊的叉子,但是在拿到兩個叉子之前不能開始進食。

進食不受意大利麪條或胃的限制,假設麪條可以無限量供應,哲學家的胃也是填不飽的。」

你可以花很多時間來解決這個問題,這裏有一個簡單的方法,例如:

1 . 抓住你左邊的叉子,如果有的話

2 . 等待右邊的叉子

2a. 如果它可用:拿起它

2B. 如果經過一段時間後,沒有叉子可用,把左邊的叉子放回原處

3 . 退後並重新開始

這種方式可能不起作用,實際上很有可能會引起死鎖。

活鎖

活鎖(livelock)是死鎖的一個特例。死鎖是指等待一個資源被釋放,而活鎖是指多個執行緒等待其他執行緒釋放資源。這些資源不斷改變狀態,但這些切來切去的執行緒卻毫無進展。

在現實生活中,活鎖可以發生在一個狹小的巷子裡,兩個人都想要穿過去,但出於禮貌,他們走在了同一邊。然後他們嘗試同時切換到了另一邊,結果又把彼此擋住了。這可以無限期地發生下去,從而產生活鎖。你之前可能經歷過這個。

嚴重爭用鎖

鎖可能導致的另一個問題是嚴重爭用鎖(Heavily Contended Lock)。想象一下收費站,如果汽車到達收費站速度比收費站的處理速度快,就會發生堵車。鎖和執行緒也是如此。如果一個鎖被嚴重爭用,那麼同步部分就執行緩慢。這將導致很多執行緒排隊,被掛起,最終會影響效能。

執行緒飢餓

如前所述,執行緒可以有不同的優先順序。執行緒優先順序可以讓我們確保特定任務將盡快得到執行。但是,如果我們將少量任務新增到低優先順序執行緒中,而將大量任務新增到高優先順序執行緒中,會發生什麼?低優先順序執行緒將會出現飢餓,因為它將得不到執行時間。結果是,低優先順序的任務將不會被執行或需要很長時間才能執行完。

優先順序倒置

一旦我們加入鎖機制,上面的執行緒飢餓就會變得很有趣。現在假設有一個低優先順序的執行緒 3,它鎖定了一個資源。高優先順序執行緒 1 想要訪問此資源,因此必須等待。另一個優先順序高於 3 的執行緒 2 將會帶來災難性的結果。因為它的優先順序高於執行緒 3,它將首先被執行。如果這個執行緒長時間執行,它將佔用執行緒 3 可以使用的所有資源。由於執行緒 3 無法執行,導致執行緒 1 阻塞,所以執行緒 2 成了餓死執行緒 1 的「兇手」。即使執行緒 1 的優先順序高於執行緒 2,情況也是如此。

太多執行緒

說了這麼多與執行緒有關的內容,還有最後一點需要提及。你可能不會遇到這種情況,但它仍然可能發生。執行緒的狀態改變其實是上下文切換。作為開發人員,我們經常抱怨在多工間切換(或被人打斷)會讓我們效率低下。如果進行上下文切換,CPU 也會發生同樣的情況。所有預載入的命令都需要重新整理,而且在短時間內它無法進行任何命令預測。

那麼如果我們經常切換執行緒會發生什麼呢?CPU 將無法再預測任何內容,從而導致效率低下。它只能執行當前命令,並且必須等待下一個,這會導致更多的開銷。

作為一般性準則,儘量不要使用太多執行緒:

「儘可能少,夠用就好。」

Swift 警告

即使你正確地完成了所有操作,可以完全控制好同步、鎖定、記憶體操作和執行緒,但仍然有一點需要注意。Swift 編譯器不保證會保留你的程式碼的執行順序,這可能導致你的同步機制不會與你編寫它們時的順序保持一致。

換一種說法:

「Swift 本身並不是 100%執行緒安全的」。

如果你想要對併發性(例如在使用 AudioUnits 時)做出 100% 的保證,可能需要回到 Objective-C。

結 論  

如你所見,併發是個複雜的話題。很多情況下都會出錯,但同時又給我們帶來好處。我們使用的大多數工具都是面向開發人員的,如果程式碼太多,將無法進行除錯。所以,謹慎選擇你的工具。

蘋果提供了一些除錯併發性的工具,例如 Activity Group 和 Breadcrumb。可惜的是,它們目前在 Swift 中不受支援(儘管有一個包裝器可用在 Activity 上)。

英文原文

https://medium.com/flawless-app-stories/parallel-programming-with-swift-what-could-possibly-go-wrong-f5bcc38b1814

課程推薦

2018 世界盃總決賽巔峰對決在即,《技術領導力 300 講專欄》超級團燃情上線。

池建強、馮大輝、左耳朵耗子、tinyfool 四位技術大佬輪番上陣,領銜開團,邀你一起拼,讓強者更強。

未經允許不得轉載:頭條楓林網 » [譯] Swift 併發程式設計的 10 大陷阱