談一談 iOS 的鎖

收錄:原文地址html

翻看目前關於 iOS 開發鎖的文章,大部分都起源於 ibireme 的 《再也不安全的 OSSpinLock》,我在看文章的時候有一些疑惑。此次主要想解決這些疑問:linux

    1. 鎖是什麼?
    1. 爲何要有鎖?
    1. 鎖的分類問題
    1. 爲何 OSSpinLock 不安全?
    1. 解決自旋鎖不安全問題有幾種方式
    1. 爲何換用其它的鎖,能夠解決 OSSpinLock 的問題?
    1. 自旋鎖和互斥鎖的關係是平行對立的嗎?
    1. 信號量和互斥量的關係
    1. 信號量和條件變量的區別

鎖是什麼

鎖 -- 是保證線程安全常見的同步工具。鎖是一種非強制的機制,每個線程在訪問數據或者資源前,要先獲取(Acquire) 鎖,並在訪問結束以後釋放(Release)鎖。若是鎖已經被佔用,其它試圖獲取鎖的線程會等待,直到鎖從新可用。ios

爲何要有鎖?

前面說到了,鎖是用來保護線程安全的工具。git

能夠試想一下,多線程編程時,沒有鎖的狀況 -- 也就是線程不安全。github

當多個線程同時對一塊內存發生讀和寫的操做,可能出現意料以外的結果:編程

程序執行的順序會被打亂,可能形成提早釋放一個變量,計算結果錯誤等狀況。數組

因此咱們須要將線程不安全的代碼 「鎖」 起來。保證一段代碼或者多段代碼操做的原子性,保證多個線程對同一個數據的訪問 同步 (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 因爲沒有時間片,沒法執行(優先級相對沒有B高)。
這種狀況形成 A 在C 以後執行,C在B以後,間接的高優先級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


相關文章
相關標籤/搜索