深刻理解 iOS 開發中的鎖

摘要

本文的目的不是介紹 iOS 中各類鎖如何使用,一方面筆者沒有大量的實戰經驗,另外一方面這樣的文章至關多,好比 iOS中保證線程安全的幾種方式與性能對比iOS 常見知識點(三):Lock。本文也不會詳細介紹鎖的具體實現原理,這會涉及到太多相關知識,筆者不敢誤人子弟。html

本文要作的就是簡單的分析 iOS 開發中常見的幾種鎖如何實現,以及優缺點是什麼,爲何會有性能上的差距,最終會簡單的介紹鎖的底層實現原理。水平有限,若是不慎有誤,歡迎交流指正。同時建議讀者在閱讀本文之前,對 OC 中各類鎖的使用方法先有大概的認識。android

在 ibireme 的 再也不安全的 OSSpinLock 一文中,有一張圖片簡單的比較了各類鎖的加解鎖性能:ios

來源:ibireme

本文會按照從上至下(速度由快至慢)的順序分析每一個鎖的實現原理。須要說明的是,加解鎖速度不表示鎖的效率,只表示加解鎖操做在執行時的複雜程度,下文會經過具體的例子來解釋。git

OSSpinLock

上述文章中已經介紹了 OSSpinLock 再也不安全,主要緣由發生在低優先級線程拿到鎖時,高優先級線程進入忙等(busy-wait)狀態,消耗大量 CPU 時間,從而致使低優先級線程拿不到 CPU 時間,也就沒法完成任務並釋放鎖。這種問題被稱爲優先級反轉。github

爲何忙等會致使低優先級線程拿不到時間片?這還得從操做系統的線程調度提及。算法

現代操做系統在管理普通線程時,一般採用時間片輪轉算法(Round Robin,簡稱 RR)。每一個線程會被分配一段時間片(quantum),一般在 10-100 毫秒左右。當線程用完屬於本身的時間片之後,就會被操做系統掛起,放入等待隊列中,直到下一次被分配時間片。swift

自旋鎖的實現原理

自旋鎖的目的是爲了確保臨界區只有一個線程能夠訪問,它的使用能夠用下面這段僞代碼來描述:數組

do {
    Acquire Lock
        Critical section  // 臨界區
    Release Lock
        Reminder section // 不須要鎖保護的代碼
}複製代碼

在 Acquire Lock 這一步,咱們申請加鎖,目的是爲了保護臨界區(Critical Section) 中的代碼不會被多個線程執行。緩存

自旋鎖的實現思路很簡單,理論上來講只要定義一個全局變量,用來表示鎖的可用狀況便可,僞代碼以下:安全

bool lock = false; // 一開始沒有鎖上,任何線程均可以申請鎖
do {
    while(lock); // 若是 lock 爲 true 就一直死循環,至關於申請鎖
    lock = true; // 掛上鎖,這樣別的線程就沒法得到鎖
        Critical section  // 臨界區
    lock = false; // 至關於釋放鎖,這樣別的線程能夠進入臨界區
        Reminder section // 不須要鎖保護的代碼 
}複製代碼

註釋寫得很清楚,就再也不逐行分析了。惋惜這段代碼存在一個問題: 若是一開始有多個線程同時執行 while 循環,他們都不會在這裏卡住,而是繼續執行,這樣就沒法保證鎖的可靠性了。解決思路也很簡單,只要確保申請鎖的過程是原子操做便可。

原子操做

狹義上的原子操做表示一條不可打斷的操做,也就是說線程在執行操做過程當中,不會被操做系統掛起,而是必定會執行完。在單處理器環境下,一條彙編指令顯然是原子操做,由於中斷也要經過指令來實現。

然而在多處理器的狀況下,可以被多個處理器同時執行的操做任然算不上原子操做。所以,真正的原子操做必須由硬件提供支持,好比 x86 平臺上若是在指令前面加上 「LOCK」 前綴,對應的機器碼在執行時會把總線鎖住,使得其餘 CPU不能再執行相同操做,從而從硬件層面確保了操做的原子性。

這些很是底層的概念無需徹底掌握,咱們只要知道上述申請鎖的過程,能夠用一個原子性操做 test_and_set 來完成,它用僞代碼能夠這樣表示:

bool test_and_set (bool *target) {
    bool rv = *target; 
    *target = TRUE; 
    return rv;
}複製代碼

這段代碼的做用是把 target 的值設置爲 1,並返回原來的值。固然,在具體實現時,它經過一個原子性的指令來完成。

自旋鎖的總結

至此,自旋鎖的實現原理就很清楚了:

bool lock = false; // 一開始沒有鎖上,任何線程均可以申請鎖
do {
    while(test_and_set(&lock); // test_and_set 是一個原子操做
        Critical section  // 臨界區
    lock = false; // 至關於釋放鎖,這樣別的線程能夠進入臨界區
        Reminder section // 不須要鎖保護的代碼 
}複製代碼

若是臨界區的執行時間過長,使用自旋鎖不是個好主意。以前咱們介紹過期間片輪轉算法,線程在多種狀況下會退出本身的時間片。其中一種是用完了時間片的時間,被操做系統強制搶佔。除此之外,當線程進行 I/O 操做,或進入睡眠狀態時,都會主動讓出時間片。顯然在 while 循環中,線程處於忙等狀態,白白浪費 CPU 時間,最終由於超時被操做系統搶佔時間片。若是臨界區執行時間較長,好比是文件讀寫,這種忙等是毫無必要的。

信號量

以前我在 介紹 GCD 底層實現的文章 中簡單描述了信號量 dispatch_semaphore_t 的實現原理,它最終會調用到 sem_wait 方法,這個方法在 glibc 中被實現以下:

int sem_wait (sem_t *sem) {
  int *futex = (int *) sem;
  if (atomic_decrement_if_positive (futex) > 0)
    return 0;
  int err = lll_futex_wait (futex, 0);
    return -1;
)複製代碼

首先會把信號量的值減一,並判斷是否大於零。若是大於零,說明不用等待,因此馬上返回。具體的等待操做在 lll_futex_wait 函數中實現,lll 是 low level lock 的簡稱。這個函數經過彙編代碼實現,調用到 SYS_futex 這個系統調用,使線程進入睡眠狀態,主動讓出時間片,這個函數在互斥鎖的實現中,也有可能被用到。

主動讓出時間片並不老是表明效率高。讓出時間片會致使操做系統切換到另外一個線程,這種上下文切換一般須要 10 微秒左右,並且至少須要兩次切換。若是等待時間很短,好比只有幾個微秒,忙等就比線程睡眠更高效。

能夠看到,自旋鎖和信號量的實現都很是簡單,這也是二者的加解鎖耗時分別排在第一和第二的緣由。再次強調,加解鎖耗時不能準確反應出鎖的效率(好比時間片切換就沒法發生),它只能從必定程度上衡量鎖的實現複雜程度。

pthread_mutex

pthread 表示 POSIX thread,定義了一組跨平臺的線程相關的 API,pthread_mutex 表示互斥鎖。互斥鎖的實現原理與信號量很是類似,不是使用忙等,而是阻塞線程並睡眠,須要進行上下文切換。

互斥鎖的常見用法以下:

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);  // 定義鎖的屬性

pthread_mutex_t mutex;
pthread_mutex_init(&mutex, &attr) // 建立鎖

pthread_mutex_lock(&mutex); // 申請鎖
    // 臨界區
pthread_mutex_unlock(&mutex); // 釋放鎖複製代碼

對於 pthread_mutex 來講,它的用法和以前沒有太大的改變,比較重要的是鎖的類型,能夠有 PTHREAD_MUTEX_NORMALPTHREAD_MUTEX_ERRORCHECKPTHREAD_MUTEX_RECURSIVE 等等,具體的特性就不作解釋了,網上有不少相關資料。

通常狀況下,一個線程只能申請一次鎖,也只能在得到鎖的狀況下才能釋放鎖,屢次申請鎖或釋放未得到的鎖都會致使崩潰。假設在已經得到鎖的狀況下再次申請鎖,線程會由於等待鎖的釋放而進入睡眠狀態,所以就不可能再釋放鎖,從而致使死鎖。

然而這種狀況常常會發生,好比某個函數申請了鎖,在臨界區內又遞歸調用了本身。辛運的是 pthread_mutex 支持遞歸鎖,也就是容許一個線程遞歸的申請鎖,只要把 attr 的類型改爲 PTHREAD_MUTEX_RECURSIVE 便可。

互斥鎖的實現

互斥鎖在申請鎖時,調用了 pthread_mutex_lock 方法,它在不一樣的系統上實現各有不一樣,有時候它的內部是使用信號量來實現,即便不用信號量,也會調用到 lll_futex_wait 函數,從而致使線程休眠。

上文說到若是臨界區很短,忙等的效率也許更高,因此在有些版本的實現中,會首先嚐試必定次數(好比 1000 次)的 test_and_test,這樣能夠在錯誤使用互斥鎖時提升性能。

另外,因爲 pthread_mutex 有多種類型,能夠支持遞歸鎖等,所以在申請加鎖時,須要對鎖的類型加以判斷,這也就是爲何它和信號量的實現相似,但效率略低的緣由。

NSLock

NSLock 是 Objective-C 以對象的形式暴露給開發者的一種鎖,它的實現很是簡單,經過宏,定義了 lock 方法:

#define MLOCK \
- (void) lock\
{\
  int err = pthread_mutex_lock(&_mutex);\
  // 錯誤處理 ……
}複製代碼

NSLock 只是在內部封裝了一個 pthread_mutex,屬性爲 PTHREAD_MUTEX_ERRORCHECK,它會損失必定性能換來錯誤提示。

這裏使用宏定義的緣由是,OC 內部還有其餘幾種鎖,他們的 lock 方法都是如出一轍,僅僅是內部 pthread_mutex 互斥鎖的類型不一樣。經過宏定義,能夠簡化方法的定義。

NSLockpthread_mutex 略慢的緣由在於它須要通過方法調用,同時因爲緩存的存在,屢次方法調用不會對性能產生太大的影響。

NSCondition

NSCondition 的底層是經過條件變量(condition variable) pthread_cond_t 來實現的。條件變量有點像信號量,提供了線程阻塞與信號機制,所以能夠用來阻塞某個線程,並等待某個數據就緒,隨後喚醒線程,好比常見的生產者-消費者模式。

如何使用條件變量

不少介紹 pthread_cond_t 的文章都會提到,它須要與互斥鎖配合使用:

void consumer () { // 消費者
    pthread_mutex_lock(&mutex);
    while (data == NULL) {
        pthread_cond_wait(&condition_variable_signal, &mutex); // 等待數據
    }
    // --- 有新的數據,如下代碼負責處理 ↓↓↓↓↓↓
    // temp = data;
    // --- 有新的數據,以上代碼負責處理 ↑↑↑↑↑↑
    pthread_mutex_unlock(&mutex);
}

void producer () {
    pthread_mutex_lock(&mutex);
    // 生產數據
    pthread_cond_signal(&condition_variable_signal); // 發出信號給消費者,告訴他們有了新的數據
    pthread_mutex_unlock(&mutex);
}複製代碼

天然咱們會有疑問:「若是不用互斥鎖,只用條件變量會有什麼問題呢?」。問題在於,temp = data; 這段代碼不是線程安全的,也許在你把 data 讀出來之前,已經有別的線程修改了數據。所以咱們須要保證消費者拿到的數據是線程安全的。

wait 方法除了會被 signal 方法喚醒,有時還會被虛假喚醒,因此須要這裏 while 循環中的判斷來作二次確認。

爲何要使用條件變量

介紹條件變量的文章很是多,但大多都對一個一個基本問題避而不談:「爲何要用條件變量?它僅僅是控制了線程的執行順序,用信號量或者互斥鎖能不能模擬出相似效果?」

網上的相關資料比較少,我簡單說一下我的見解。信號量能夠必定程度上替代 condition,可是互斥鎖不行。在以上給出的生產者-消費者模式的代碼中, pthread_cond_wait 方法的本質是鎖的轉移,消費者放棄鎖,而後生產者得到鎖,同理,pthread_cond_signal 則是一個鎖從生產者到消費者轉移的過程。

若是使用互斥鎖,咱們須要把代碼改爲這樣:

void consumer () { // 消費者
    pthread_mutex_lock(&mutex);
    while (data == NULL) {
        pthread_mutex_unlock(&mutex);
        pthread_mutex_lock(&another_lock)  // 至關於 wait 另外一個互斥鎖
        pthread_mutex_lock(&mutex);
    }
    pthread_mutex_unlock(&mutex);
}複製代碼

這樣作存在的問題在於,在等待 another_lock 以前, 生產者有可能先執行代碼, 從而釋放了 another_lock。也就是說,咱們沒法保證釋放鎖和等待另外一個鎖這兩個操做是原子性的,也就沒法保證「先等待、後釋放 another_lock」 這個順序。

用信號量則不存在這個問題,由於信號量的等待和喚醒並不須要知足前後順序,信號量只表示有多少個資源可用,所以不存在上述問題。然而與 pthread_cond_wait 保證的原子性鎖轉移相比,使用信號量彷佛存在必定風險(暫時沒有查到非原子性操做有何不妥)。

不過,使用 condition 有一個好處,咱們能夠調用 pthread_cond_broadcast 方法通知全部等待中的消費者,這是使用信號量沒法實現的。

NSCondition 的作法

NSCondition 實際上是封裝了一個互斥鎖和條件變量, 它把前者的 lock 方法和後者的 wait/signal 統一在 NSCondition 對象中,暴露給使用者:

- (void) signal {
  pthread_cond_signal(&_condition);
}

// 其實這個函數是經過宏來定義的,展開後就是這樣
- (void) lock {
  int err = pthread_mutex_lock(&_mutex);
}複製代碼

它的加解鎖過程與 NSLock 幾乎一致,理論上來講耗時也應該同樣(實際測試也是如此)。在圖中顯示它耗時略長,我猜想有多是測試者在每次加解鎖的先後還附帶了變量的初始化和銷燬操做。

NSRecursiveLock

上文已經說過,遞歸鎖也是經過 pthread_mutex_lock 函數來實現,在函數內部會判斷鎖的類型,若是顯示是遞歸鎖,就容許遞歸調用,僅僅將一個計數器加一,鎖的釋放過程也是同理。

NSRecursiveLockNSLock 的區別在於內部封裝的 pthread_mutex_t 對象的類型不一樣,前者的類型爲 PTHREAD_MUTEX_RECURSIVE

NSConditionLock

NSConditionLock 藉助 NSCondition 來實現,它的本質就是一個生產者-消費者模型。「條件被知足」能夠理解爲生產者提供了新的內容。NSConditionLock 的內部持有一個 NSCondition 對象,以及 _condition_value 屬性,在初始化時就會對這個屬性進行賦值:

// 簡化版代碼
- (id) initWithCondition: (NSInteger)value {
    if (nil != (self = [super init])) {
        _condition = [NSCondition new]
        _condition_value = value;
    }
    return self;
}複製代碼

它的 lockWhenCondition 方法其實就是消費者方法:

- (void) lockWhenCondition: (NSInteger)value {
    [_condition lock];
    while (value != _condition_value) {
        [_condition wait];
    }
}複製代碼

對應的 unlockWhenCondition 方法則是生產者,使用了 broadcast 方法通知了全部的消費者:

- (void) unlockWithCondition: (NSInteger)value {
    _condition_value = value;
    [_condition broadcast];
    [_condition unlock];
}複製代碼

@synchronized

這實際上是一個 OC 層面的鎖, 主要是經過犧牲性能換來語法上的簡潔與可讀。

咱們知道 @synchronized 後面須要緊跟一個 OC 對象,它其實是把這個對象當作鎖來使用。這是經過一個哈希表來實現的,OC 在底層使用了一個互斥鎖的數組(你能夠理解爲鎖池),經過對對象去哈希值來獲得對應的互斥鎖。

具體的實現原理能夠參考這篇文章: 關於 @synchronized,這兒比你想知道的還要多

參考資料

  1. pthread_mutex_lock
  2. ThreadSafety
  3. Difference between binary semaphore and mutex
  4. 關於 @synchronized,這兒比你想知道的還要多
  5. pthread_mutex_lock.c 源碼
  6. [Pthread] Linux中的線程同步機制(二)--In Glibc
  7. pthread的各類同步機制
  8. pthread_cond_wait
  9. Conditional Variable vs Semaphore
相關文章
相關標籤/搜索