文章摘要: 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 四位技術大佬輪番上陣,領銜開團,邀你一起拼,讓強者更強。