關於 iOS/OS X 執行緒安全的基礎知識

發表於2016-01-08
處理多併發和可重入性問題,是每個庫發展過程中面臨的比較困難的挑戰之一。在Parse平臺上,我們盡最大的努力保證你在使用我的SDKs時所做的操作都是執行緒安全的,保證不會出現效能問題。
在這篇文章中我們將會複習一些關於如何以簡潔、安全、乾淨的方式處理多併發和競爭條件下的基本概念。 

首先,在進入細節討論之前,我們先定義以下概念:

  • 執行緒:它是作業系統執行的一個上下文程式,並且可以同時 存在多個執行緒。
  • 併發性:在程式執行過程中,多個執行緒執行時共享同一資源的現象。
  • 可重入性: 通過顯式遞迴,軟體/硬體中斷,或者其他方法,可以重新進入執行狀態下的函式的現行。
  • 操作的原子性:一個保證操作完成或者失敗的屬性,這個屬性永遠不會產生一箇中間狀態或者一個無效的狀態。
  • 執行緒安全:一個函式如果是執行緒安全的,就是說它不會產生無效狀態,也不能被觀察,同時也不能進入併發態。
  • 重入樣式:一個函式如果是可重入的,就是說它不會產生無效狀態,也不能被觀察,同時也不能進入併發態。

談到執行緒安全我們經常討論的首要事情是執行緒安全的是天生難以實現的。由於執行緒排程方式、記憶體垃圾回收,快取錯誤,分支預測等等複雜的工作,與執行緒有關的問題很難被記錄下來,也很難修復。鑑於這些因素,無論何時只要有可能,不要寫可能陷入多執行緒環境的程式碼。如果你遵守下面的指導原則,避免多執行緒環境就會相當容易:

  • 如果可能的話,不要有可變狀態。
  • 你的程式碼有競爭條件。
  • 必要的時候使用執行緒本地儲存而不是全域性狀態。
  • 困惑的時候,使用執行緒鎖。
  • 最後確保你的程式碼有競爭條件(儘管你認為那是不可能的)。

競爭條件

競爭狀態是多執行緒系統的剋星。當你不直接控制排程(如發生在單個執行緒的事情),你怎麼能確保事情發生的順序符合你的預期?網上有很多好的關於如何追蹤競態條件的建議,但很少有關於如何避免它們的介紹。

大多數競爭狀態是由共享可變狀態引起的,如以下事例:

如果執行緒1的someCondition變數的值為true ,_shareState的值是0還是1?這取決於執行緒2的狀態,無論執行緒1是否有條件和指定值。

可變狀態並不一定意味著變數。包括檔案系統、網路、系統呼叫,等等的狀態可能在你的應用程式之外被改變。

States and Copying

避免可變狀態的最好方法之一是有一個嚴格的關於如何把管理的狀態作為一個整體的指導方針。在Parse庫中,我們堅持一下三個規則:

  1. 把狀態和能改變狀態的程式碼相分離。這可以讓你把閱讀和狀態突變的關注點相分離,並允許你線上程的程式碼中實現更好的邏輯。
  2. 通過mutable copy傳遞任意物件。通過引用傳遞物件可能會產生併發資源的改變,為了防止這種情況的發生,你需要某種形式的資源同步。
  3. 每當遇到困惑的時間,就使用執行緒鎖(lock).這可能會使應用程式變慢,但是比在1000個選出一個的競爭環境下從而使應用崩潰的情況要好。

記住全域性狀態是不好的(包括單例),儘可能的避免使用它。在Parse庫中,我們更喜歡使用依賴注入(也稱控制反轉)設計模式而不是單例(例如:-initWithObjectController: vs [ObjectController shareController]),原因是它幫助我們一直記錄物件的用法,同時也加強我們對執行緒的推理能力,如果必要的話,可以使用本地執行緒儲存替代全域性變數。

正如上面提到的,可變的狀態(以及全域性變數)使處理併發性更難。所以不惜一切代價避免它。

原子性

正如上文中所說的那樣,原子性的定義如下:

一個保證操作完成或者失敗的屬性,這個屬性永遠不會產生一箇中間狀態或者一個無效的狀態。

這個定義看起來很神祕,有點難以理解。但是,它在實踐中這意味著什麼呢?

假如你有一個計數變數y,它需要在多執行緒裡被更新。解決這問題比較天真的方法是讓y直接增加,例如y++。然而,這種做法有一個重要缺陷,就是如果有兩個執行緒同時增加y,那怎麼辦。這就迫使你去找其他解決方案。

有一個解決方案是在計數變數上附加一個鎖,但這將顯著降低效能。另一個解決方案(根據情況)可能是在每一個單獨的執行緒的上使用各子的計數器,但這增加了程式的記憶體使用和認知負荷。

但是,我們還有更好的方法。使用指示器的某些特殊指令,這些指令是從中分離出來的功能,他們能確保在一個記憶體地址上所有的操作都是正確同步的。這些操作是指示器發出的,而不是系統操作。那些建立無鎖資料結構的基礎理論是很實用的,但是不在本文的討論範圍中。

一般來說,如果你在一個指定的地址上操作是原子性的,那麼沒有讀取那個地址不可能使你的應用處於無效狀態。當這些引數一旦和原子性屬性聯合,就能確保單個引數不能處於無效狀態。注意作為一個整體的物件仍然可能處於無效狀態,原因是每個原子性操作的表現是完全獨立於其他正在另外那些記憶體地址上執行的原子性操作的。

當原子性不能滿足你的目的時,在鎖定執行緒安全方面,你的確有很多傳統的方法。鎖存在多種形式,問題是要在眾多的形式中找到一個最好的方式,來解決許多矛盾重生的困境。下面我們將討論iOS/OS中一些預設的情況。

在討論鎖之前,我們首先要知道什麼時候需要鎖。線上程安全開發時最大的錯誤之一是輕易的大量使用鎖。當然,如果你你鎖定每一個呼叫物件的方法,那是不可能有競爭條件的。但是,如果你在獲取可變狀態的時候,將狀態和執行緒分離,這樣會更好。

下面,我們將演示幾種一下幾種鎖的,一下面的例子開始

這簡單的函式,看起來是完全沒有問題,但是它既不是執行緒安全的也不是可重入的。使用者段程式碼的時候,會出現很多問題。

在併發的實際使用例項中,操作符*=不是原子性的。這就意味著如果有兩個執行緒同時呼叫incrementFooBy:方法,我們最終會得到一箇中間值,並且它不代表任何有效的狀態。

在可重入的實際使用例項中,如果在上面例子中的乘法和賦值中間引起了一箇中斷,我們會遇到和上面相似的問題,就是我們會得到一個奇怪的中間值。

所有上面的程式碼不能正常工作,我們需要做一些改變使它更好。

方法1:使用 @synchronized 關鍵字

這解決了併發問題和可重入問題,但是也產生了幾個新問題。第一,很明顯的是我們通過同步物件本身,限制了其他執行緒對該物件的同步,如果大量使用這個函式,將會出現很糟糕的情況。

第二問題是由@synchronized帶來的,眾數週知,@synchronized的在效能方面的表現是很糟糕的。但是,在Objective – C 中,它是建立鎖的一個最簡單的方法。這並不意味著不存在更好的方法,建立鎖。

方法2:序列佇列

從某種意義上說,在你的Cocoa/Cocoa Touch程式設計生涯,你一定能接觸到序列佇列中的一個,那就是主執行緒。一個序列的排程佇列是一個以線性方式執行的任務列表,這些任務都是來自OS系統的執行緒。然而,排程佇列有一些獨特的特性使它比@synchronized更適合建立執行緒鎖。

  • 除了主佇列,所有的排程佇列將會忽略訊號中斷,這就使得可重入資源更加明顯的複合邏輯。
  • 通過他們的QoS系統,排程佇列不受優先順序反轉的控制。
  • 可以通過設定延遲執行,而不破壞同步模型。

然而,當你的資源是相互排斥的時候,使用排程佇列會產生以下缺點包括:

  • 所有的排程佇列都是不可重入的,這就意味著如果你在當前佇列同步就會產生死鎖現象。
  • 排程佇列物件與一個簡單的OSSpinLock相比佔記憶體容量比較大,最短僅約128位元組(加上額外的空間內部指標),OSSpinLock只有4個位元組。
  • 由於需要__block變數接收,dispatch_sync塊返回值有時候變得有點令人討厭。
  • 序列佇列不能很好地處理異常排程佇列。

在大多數場景下這些效能優勢得權衡是值得的,並且要廣泛應用在SDK中。

方法3:並行佇列

在讀寫平衡的場景中(例如相同數量的get和set方法),方法2是很好的。但是,在實際生活中,那種情況是很少出現的。你經常遇到的情況是多次讀取某個資料,只是偶爾去寫資料。

排程以並行對列的形式建立在支援所謂的讀寫鎖的基礎之上。但是,他們的工作和其他大多數佇列一樣,他們試圖讓更多的執行人儘可能的單獨訪問dispatch_barrier塊。這就允許佇列在並行佇列的上下文中單獨執行,並幫助我們加速無競爭條件下得用例。

上面程式碼的另一個優點是,它使我們更清楚的知道那些函式更新例項變數,而那些函式沒有。

知道並行佇列的效能開銷比序列佇列的開銷要大得多時很重要的。在競爭環境下(例如dispatch_barrier_sync的多次呼叫),有一個顯而易見的基準就是一個並行佇列

在其內部旋轉鎖上花費的時間比一個序列佇列多的多。

結論

在Parse庫中,我們努力創造最好的APIs介面,最好的執行緒支援。我們在這個SDK內部使用的大量機制,對任何一個移動應用和都是最好的。請繼續關注我們,未來幾周我們會繼續釋出類似的文章。我們會分享更多關於測試理念,知識等等。

相關文章