又到了春天挪坑的季節,想起多次被問及到鎖的概念,決定好好總結一番。
翻看目前關於 iOS 開發鎖的文章,大部分都起源於 ibireme 的 《不再安全的 OSSpinLock》,我在看文章的時候有一些疑惑。這次主要想解決這些疑問:
- 鎖是什麼?
- 為什麼要有鎖?
- 鎖的分類問題
- 為什麼 OSSpinLock 不安全?
- 解決自旋鎖不安全問題有幾種方式
- 為什麼換用其它的鎖,可以解決 OSSpinLock 的問題?
- 自旋鎖和互斥鎖的關係是平行對立的嗎?
- 訊號量和互斥量的關係
- 訊號量和條件變數的區別
鎖是什麼
鎖 -- 是保證執行緒安全常見的同步工具。鎖是一種非強制的機制,每一個執行緒在訪問資料或者資源前,要先獲取(Acquire) 鎖,並在訪問結束之後釋放(Release)鎖。如果鎖已經被佔用,其它試圖獲取鎖的執行緒會等待,直到鎖重新可用。
為什麼要有鎖?
前面說到了,鎖是用來保護執行緒安全的工具。
可以試想一下,多執行緒程式設計時,沒有鎖的情況 -- 也就是執行緒不安全。
當多個執行緒同時對一塊記憶體發生讀和寫的操作,可能出現意料之外的結果:
程式執行的順序會被打亂,可能造成提前釋放一個變數,計算結果錯誤等情況。
所以我們需要將執行緒不安全的程式碼 “鎖” 起來。保證一段程式碼或者多段程式碼操作的原子性,保證多個執行緒對同一個資料的訪問 同步 (Synchronization)。
屬性設定 atomic
上面提到了原子性,我馬上想到了屬性關鍵字裡, atomic 的作用。
設定 atomic 之後,預設生成的 getter 和 setter 方法執行是原子的。
但是它只保證了自身的讀/寫操作,卻不能說是執行緒安全。
如下情況:
//thread A
for (int i = 0; i < 100000; i ++) {
if (i % 2 == 0) {
self.arr = @[@"1", @"2", @"3"];
}else {
self.arr = @[@"1"];
}
NSLog(@"Thread A: %@\n", self.arr);
}
//thread B
if (self.arr.count >= 2) {
NSString* str = [self.arr objectAtIndex:1];
}
複製程式碼
就算在 thread B 中針對 arr 陣列進行了大小判斷,但是仍然可能在 objectAtIndex:
操作時被改變陣列長度,導致出錯。這種情況宣告為 atomic 也沒有用。
而解決方式,就是進行加鎖。
需要注意的是,讀/寫的操作都需要加鎖,不僅僅是對一段程式碼加鎖。
鎖的分類
鎖的分類方式,可以根據鎖的狀態,鎖的特性等進行不同的分類,很多鎖之間其實並不是並列的關係,而是一種鎖下的不同實現。關於鎖的分類,可以參考 Java中的鎖分類 看一下。
自旋鎖和互斥鎖的關係
很多談論鎖的文章,都會提到互斥鎖,自旋鎖。很少有提到它們的關係,其實自旋鎖,也是互斥鎖的一種實現,而 spin lock
和 mutex
兩者都是為了解決某項資源的互斥使用,在任何時刻只能有一個保持者。
區別在於 spin lock
和 mutex
排程機制上有所不同。
OSSpinLock
OSSpinLock 是一種自旋鎖。它的特點是線上程等待時會一直輪詢,處於忙等狀態。自旋鎖由此得名。
自旋鎖看起來是比較耗費 cpu 的,然而在互斥臨界區計算量較小的場景下,它的效率遠高於其它的鎖。
因為它是一直處於 running 狀態,減少了執行緒切換上下文的消耗。
為什麼 OSSpinLock 不再安全?
關於 OSSpinLock 不再安全,原因就在於優先順序反轉問題。
優先順序反轉(Priority Inversion)
什麼情況叫做優先順序反轉?
wikipedia 上是這麼定義的:
優先順序倒置,又稱優先順序反轉、優先順序逆轉、優先順序翻轉,是一種不希望發生的任務排程狀態。在該種狀態下,一個高優先順序任務間接被一個低優先順序任務所搶先(preemtped),使得兩個任務的相對優先順序被倒置。 這往往出現在一個高優先順序任務等待訪問一個被低優先順序任務正在使用的臨界資源,從而阻塞了高優先順序任務;同時,該低優先順序任務被一個次高優先順序的任務所搶先,從而無法及時地釋放該臨界資源。這種情況下,該次高優先順序任務獲得執行權。
再消化一下
有:高優先順序任務A / 次高優先順序任務B / 低優先順序任務C / 資源Z 。 A 等待 C 使用 Z,而 B 並不需要 Z,搶先獲得時間片執行。C 由於沒有時間片,無法執行。 這種情況造成 A 在 B 之後執行,使得優先順序被倒置了。 而如果 A 等待資源時不是阻塞等待,而是忙迴圈,則可能永遠無法獲得資源。此時 C 無法與 A 爭奪 CPU 時間,從而 C 無法執行,進而無法釋放資源。造成的後果,就是 A 無法獲得 Z 而繼續推進。
而 OSSpinLock 忙等的機制,就可能造成高優先順序一直 running ,佔用 cpu 時間片。而低優先順序任務無法搶佔時間片,變成遲遲完不成,不釋放鎖的情況。
優先順序反轉的解決方案
關於優先順序反轉一般有以下三種解決方案
優先順序繼承
優先順序繼承,故名思義,是將佔有鎖的執行緒優先順序,繼承等待該鎖的執行緒高優先順序,如果存在多個執行緒等待,就取其中之一最高的優先順序繼承。
優先順序天花板
優先順序天花板,則是直接設定優先順序上限,給臨界區一個最高優先順序,進入臨界區的程式都將獲得這個高優先順序。
如果其他試圖進入臨界區的程式的優先順序,都低於這個最高優先順序,那麼優先順序反轉就不會發生。
禁止中斷
禁止中斷的特點,在於任務只存在兩種優先順序:可被搶佔的 / 禁止中斷的 。
前者為一般任務執行時的優先順序,後者為進入臨界區的優先順序。
通過禁止中斷來保護臨界區,沒有其它第三種的優先順序,也就不可能發生反轉了。
為什麼使用其它的鎖,可以解決優先順序反轉?
我們看到很多本來使用 OSSpinLock 的知名專案,都改用了其它方式替代,比如 pthread_mutex
和 dispatch_semaphore
。
那為什麼其它的鎖,就不會有優先順序反轉的問題呢?如果按照上面的想法,其它鎖也可能出現優先順序反轉。
原因在於,其它鎖出現優先順序反轉後,高優先順序的任務不會忙等。因為處於等待狀態的高優先順序任務,沒有佔用時間片,所以低優先順序任務一般都能進行下去,從而釋放掉鎖。
執行緒排程
為了幫助理解,要提一下有關執行緒排程
的概念。
無論多核心還是單核,我們的執行緒執行總是 "併發" 的。
當 cpu 數量大於等於執行緒數量,這個時候是真正併發,可以多個執行緒同時執行計算。
當 cpu 數量小於執行緒數量,總有一個 cpu 會執行多個執行緒,這時候"併發"就是一種模擬出來的狀態。作業系統通過不斷的切換執行緒,每個執行緒執行一小段時間,讓多個執行緒看起來就像在同時執行。這種行為就稱為 "執行緒排程(Thread Schedule)"。
執行緒狀態
線上程排程中,執行緒至少擁有三種狀態 : 執行(Running),就緒(Ready),等待(Waiting)。
處於 Running 的執行緒擁有的執行時間,稱為 時間片 (Time Slice),時間片 用完時,進入 Ready 狀態。如果在 Running 狀態,時間片沒有用完,就開始等待某一個事件(通常是 IO 或 同步 ),則進入 Waiting 狀態。
如果有執行緒從 Running 狀態離開,排程系統就會選擇一個 Ready 的執行緒進入 Running 狀態。而 Waiting 的執行緒等待的事件完成後,就會進入 Ready 狀態。
dispatch_semaphore
dispatch_semaphore 是 GCD 中同步的一種方式,與他相關的只有三個函式,一個是建立訊號量,一個是等待訊號,一個是傳送訊號。
訊號量機制
訊號量中,二元訊號量,是一種最簡單的鎖。只有兩種狀態,佔用和非佔用。二元訊號量適合唯一一個執行緒獨佔訪問的資源。而多元訊號量簡稱 訊號量(Semaphore)。
訊號量和互斥量的區別
訊號量是允許併發訪問的,也就是說,允許多個執行緒同時執行多個任務。訊號量可以由一個執行緒獲取,然後由不同的執行緒釋放。
互斥量只允許一個執行緒同時執行一個任務。也就是同一個程獲取,同一個執行緒釋放。
之前我對,互斥量只由一個執行緒獲取和釋放,理解的比較狹義,以為這裡的獲取和釋放,是系統強制要求的,用 NSLock
實驗發現它可以在不同執行緒獲取和釋放,感覺很疑惑。
實際上,的確能在不同執行緒獲取/釋放同一個互斥鎖,但互斥鎖本來就用於同一個執行緒中上鎖和解鎖。這裡的意義更多在於程式碼使用的層面。
關鍵在於,理解訊號量可以允許 N 個訊號量允許 N 個執行緒併發地執行任務。
@synchonized
@synchonized 是一個遞迴鎖。
遞迴鎖
遞迴鎖也稱為可重入鎖。互斥鎖可以分為非遞迴鎖/遞迴鎖兩種,主要區別在於:同一個執行緒可以重複獲取遞迴鎖,不會死鎖; 同一個執行緒重複獲取非遞迴鎖,則會產生死鎖。
因為是遞迴鎖,我們可以寫類似這樣的程式碼:
- (void)testLock{
if(_count>0){
@synchronized (obj) {
_count = _count - 1;
[self testLock];
}
}
}
複製程式碼
而如果換成 NSLock ,它就會因為遞迴發生死鎖了。
實際使用問題
如果 obj 為 nil,或者 obj 地址不同,鎖會失效。
所以我們要防止如下的情況:
@synchronized (obj) {
obj = newObj;
}
複製程式碼
這裡的 obj 被更改後,等到其它執行緒訪問時,就和沒加鎖一樣直接進去了。
另外一種情況,就是 @synchonized(self)
. 不少程式碼都是直接將self傳入@synchronized當中,而 self
很容易作為一個外部物件,被呼叫和修改。所以它和上面是一樣的情況,需要避免使用。
正確的做法是什麼?obj 應當傳入一個類內部維護的NSObject物件,而且這個物件是對外不可見的,不被隨便修改的。
pthread_mutex
pthread 定義了一組跨平臺的執行緒相關的 API,其中可以使用 pthread_mutex 作為互斥鎖。
pthread_mutex
不是使用忙等,而是同訊號量一樣,會阻塞執行緒並進行等待,呼叫時進行執行緒上下文切換。
pthread_mutex
本身擁有設定協議的功能,通過設定它的協議,來解決優先順序反轉:
pthread_mutexattr_setprotocol(pthread_mutexattr_t *attr, int protocol)
其中協議型別包括以下幾種:
- PTHREAD_PRIO_NONE:執行緒的優先順序和排程不會受到互斥鎖擁有權的影響。
- PTHREAD_PRIO_INHERIT:當高優先順序的等待低優先順序的執行緒鎖定互斥量時,低優先順序的執行緒以高優先順序執行緒的優先順序執行。這種方式將以繼承的形式傳遞。當執行緒解鎖互斥量時,執行緒的優先順序自動被降到它原來的優先順序。該協議就是支援優先順序繼承型別的互斥鎖,它不是預設選項,需要在程式中進行設定。
- PTHREAD_PRIO_PROTECT:當執行緒擁有一個或多個使用 PTHREAD_PRIO_PROTECT 初始化的互斥鎖時,此協議值會影響其他執行緒(如 thrd2)的優先順序和排程。thrd2 以其較高的優先順序或者以 thrd2 擁有的所有互斥鎖的最高優先順序上限執行。基於被 thrd2 擁有的任一互斥鎖阻塞的較高優先順序執行緒對於 thrd2的排程沒有任何影響。
設定協議型別為 PTHREAD_PRIO_INHERIT
,運用優先順序繼承的方式,可以解決優先順序反轉的問題。
而我們在 iOS 中使用的 NSLock,NSRecursiveLock 等都是基於 pthread_mutex 做實現的。
NSLock
NSLock 屬於 pthread_mutex 的一層封裝, 設定了屬性為 PTHREAD_MUTEX_ERRORCHECK
。
它會損失一定效能換來錯誤提示。並簡化直接使用 pthread_mutex 的定義。
NSCondition
NSCondition 是通過 pthread 中的條件變數(condition variable) pthread_cond_t 來實現的。
條件變數
線上程間的同步中,有這樣一種情況: 執行緒 A 需要等條件 C 成立,才能繼續往下執行.現在這個條件不成立,執行緒 A 就阻塞等待. 而執行緒 B 在執行過程中,使條件 C 成立了,就喚醒執行緒 A 繼續執行。
對於上述情況,可以使用條件變數來操作。
條件變數,類似訊號量,提供執行緒阻塞與訊號機制,可以用來阻塞某個執行緒,等待某個資料就緒後,隨後喚醒執行緒。
一個條件變數總是和一個互斥量搭配使用。
而 NSCondition 其實就是封裝了一個互斥鎖和條件變數,互斥鎖的 lock/unlock 方法和後者的 wait/signal 統一封裝在 NSCondition 物件中,暴露給使用者。
用條件變數控制執行緒同步,最為經典的例子就是 生產者-消費者問題。
生產者-消費者問題
生產者消費者問題,是一個著名的執行緒同步問題,該問題描述如下:
有一個生產者在生產產品,這些產品將提供給若干個消費者去消費。要求讓生產者和消費者能併發執行,在兩者之間設定一個具有多個緩衝區的緩衝池,生產者將它生產的產品放入一個緩衝區中,消費者可以從緩衝區中取走產品進行消費,顯然生產者和消費者之間必須保持同步,即不允許消費者到一個空的緩衝區中取產品,也不允許生產者向一個已經放入產品的緩衝區中再次投放產品。
我們可以剛好可以使用 NSCondition 解決生產者-消費者問題。具體的程式碼放置在文末的 Demo 裡了。
這裡需要注意,實際操作 NSCondition 做 wait 操作時,如果用 if 判斷:
if(count==0){
[condition wait];
}
複製程式碼
上面這樣是不能保證消費者是執行緒安全的。
因為 NSCondition 可以給每個執行緒分別加鎖,但加鎖後不影響其他執行緒進入臨界區。所以 NSCondition 使用 wait 並加鎖後,並不能真正保證執行緒的安全。
當一個 signal 操作發出時,如果有兩個執行緒都在做 消費者 操作,那同時都會消耗掉資源,於是繞過了檢查。
例如我們的條件是,count == 0 執行等待。
假設當前 count = 0,執行緒A 要判斷到 count == 0,執行等待;
執行緒B 執行了 count = 1 ,並喚醒執行緒A 執行 count - 1 ,同時執行緒C 也判斷到 count > 0 。因為處在不同的執行緒鎖,同樣判斷執行了 count - 1。2 個執行緒都會執行 count - 1,但是 count = 1,實際就出現count = -1 的情況。
所以為了保證消費者操作的正確,使用 while 迴圈中的判斷,進行二次確認:
while (count == 0) {
[condition wait];
}
複製程式碼
條件變數和訊號量的區別
每個訊號量有一個與之關聯的值,發出時+1,等待時-1,任何執行緒都可以發出一個訊號,即使沒有執行緒在等待該訊號量的值。
可是對於條件變數,例如 pthread_cond_signal 發出訊號後,沒有任何執行緒阻塞在 pthread_cond_wait 上,那這個條件變數上的訊號會直接丟失掉。
NSConditionLock
NSConditionLock 稱為條件鎖,只有 condition 引數與初始化時候的 condition 相等,lock 才能正確進行加鎖操作。
這裡分清兩個概念:
unlockWithCondition:
,它是先解鎖,再修改 condition 引數的值。 並不是當 condition 符合某個件值去解鎖。lockWhenCondition:
,它與unlockWithCondition:
不一樣,不會修改 condition 引數的值,而是符合 condition 的值再上鎖。
在這裡可以利用 NSConditionLock 實現任務之間的依賴.
NSRecursiveLock
NSRecursiveLock 和前面提到的 @synchonized 一樣,是一個遞迴鎖。
NSRecursiveLock 與 NSLock 的區別在於內部封裝的 pthread_mutex_t 物件的型別不同,NSRecursiveLock
的型別被設定為 PTHREAD_MUTEX_RECURSIVE
。
NSDistributedLock
這裡順帶提一下 NSDistributedLock
, 是 macOS 下的一種鎖.
蘋果文件 對於NSDistributedLock 的描述是:
A lock that multiple applications on multiple hosts can use to restrict access to some shared resource, such as a file
意思是說,它是一個用在多個主機間的多應用的鎖,可以限制訪問一些共享資源,例如檔案。
按字面意思翻譯,NSDistributedLock
應該就叫做 分散式鎖。但是看概念和資料,在 解決NSDistributedLock程式互斥鎖的死鎖問題(一) 裡面看到,NSDistributedLock 更類似於檔案鎖的概念。 有興趣的可以看一看 Linux 2.6 中的檔案鎖
其它保證執行緒安全的方式
除了用鎖之外,有其它方法保證執行緒安全嗎?
使用單執行緒訪問
首先,儘量避免多執行緒的設計。因為多執行緒訪問會出現很多不可控制的情況。有些情況即使上鎖,也無法保證百分之百的安全,例如自旋鎖的問題。
不對資源做修改
而如果還是得用多執行緒,那麼避免對資源做修改。
如果都是訪問共享資源,而不去修改共享資源,也可以保證執行緒安全。
比如 NSArry 作為不可變類是執行緒安全的。然而它們的可變版本,比如 NSMutableArray 是執行緒不安全的。事實上,如果是在一個佇列中序列地進行訪問的話,在不同執行緒中使用它們也是沒有問題的。
總結
如果實在要使用多執行緒,也沒有必要過分追求效率,而更多的考慮執行緒安全問題,使用對應的鎖。
對於平時編寫應用裡的多執行緒程式碼,還是建議用 @synchronized,NSLock 等,可讀性和安全性都好,多執行緒安全比多執行緒效能更重要。
這裡提供了我學習鎖用的程式碼,感興趣的可以看一看 實驗 Demo