深入理解iOS開發中的鎖

来源:https://www.cnblogs.com/chengxyyh/archive/2020/06/19/13162834.html
-Advertisement-
Play Games

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


摘要

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

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

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

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

OSSpinLock

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

為什麼忙等會導致低優先順序線程拿不到時間片?這還得從操作系統的線程調度說起。

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

自旋鎖的實現原理

自旋鎖的目的是為了確保臨界區只有一個線程可以訪問,它的使用可以用下麵這段偽代碼來描述:

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 迴圈,他們都不會在這裡卡住,而是繼續執行,這樣就無法保證鎖的可靠性了。解決思路也很簡單,只要確保申請鎖的過程是原子操作即可。

作為一個開發者,有一個學習的氛圍跟一個交流圈子特別重要,這是一個我的iOS交流群:519832104 不管你是小白還是大牛歡迎入駐,分享經驗,討論技術,大家一起交流學習成長!

另附上一份各好友收集的大廠面試題,需要iOS開發學習資料、面試真題,可以添加iOS開發進階交流群,進群可自行下載!

點擊此處,立即與iOS大牛交流學習

原子操作

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

然而在多處理器的情況下,能夠被多個處理器同時執行的操作任然算不上原子操作。因此,真正的原子操作必須由硬體提供支持,比如 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 次)的 testandtest,這樣可以在錯誤使用互斥鎖時提高性能。

另外,由於 pthread_mutex 有多種類型,可以支持遞歸鎖等,因此在申請加鎖時,需要對鎖的類型加以判斷,這也就是為什麼它和信號量的實現類似,但效率略低的原因。

NSLock

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

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

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

這裡使用巨集定義的原因是,OC 內部還有其他幾種鎖,他們的 lock 方法都是一模一樣,僅僅是內部 pthread_mutex 互斥鎖的類型不同。通過巨集定義,可以簡化方法的定義。

NSLock 比 pthread_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);
}

這樣做存在的問題在於,在等待 anotherlock 之前, 生產者有可能先執行代碼, 從而釋放了 anotherlock。也就是說,我們無法保證釋放鎖和等待另一個鎖這兩個操作是原子性的,也就無法保證“先等待、後釋放 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 函數來實現,在函數內部會判斷鎖的類型,如果顯示是遞歸鎖,就允許遞歸調用,僅僅將一個計數器加一,鎖的釋放過程也是同理。

NSRecursiveLock 與 NSLock 的區別在於內部封裝的 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. pthreadmutexlock
  2. ThreadSafety
  3. Difference between binary semaphore and mutex
  4. 關於 @synchronized,這兒比你想知道的還要多
  5. pthreadmutexlock.c 源碼
  6. [Pthread] Linux中的線程同步機制(二)--In Glibc
  7. pthread的各種同步機制
  8. pthreadcondwait
  9. Conditional Variable vs Semaphore

點擊此處,立即與iOS大牛交流學習


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 【重點】 內置函數 -- 1. 字元串方法 --Left(): 返回字元串從左開始數,指定數量的字元串select left('的事撒大大多大多所',4)select left('的事撒大大多大多所',4)+'...' --Right():返回字元串從右開始數,指定數量的字元串select righ ...
  • 視圖的創建 、 --create view 視圖名 as 查詢語句--註意:視圖查詢中的欄位不能重名-- 視圖中的數據是‘假數據’,真實數據在數據表中,如果數據表中被修改了,則視圖中的數據也受影響-- 使用視圖和使用數據表方法一樣。視圖也可排序、分組、設置條件,還可以嵌套 create view e ...
  • 查詢 select * from 表名 --簡單查詢 查詢指定列 select 指定的列 from 表名select id,name,sex,age from Employee--註:select age*14 from Employee --簡單查詢--查詢前幾行信息 select top 幾行 ...
  • Oracle中獲取字元串下標、截取字元串 獲取下標: SELECT INSTR('AAA-BBB-CCC-DDD', '-', 1, 1) as 字元位置 FROM dual 截取字元串: SELECT SUBSTR('AAA-BBB-CCC-DDD', 1, 3) as 截取字元串 FROM DU ...
  • 本文更新於2019-06-19,使用MySQL 5.7,操作系統為Deepin 15.4。 數值類型 整數類型 type[(m)] [UNSIGNED] [ZEROFILL] [AUTO_INCREMENT] 類型 位元組 最小值 最大值 TINYINT 1 有符號-128,無符號0 有符號127,無 ...
  • --1.使用SQL語句創建名稱為SHWLW-News-DB的資料庫。(3分)create database SHWLW_News_DBuse SHWLW_News_DB--2.按數據字典要求創建新聞分類表結構。(5分)新聞分類(NewsType)表結構:create table NewsType( ...
  • --數據表約束:通過制定一些規則,使存入資料庫的數據規範、正確、完整。 --非空約束 該欄位不能為空 關鍵字:not null --唯一約束 該欄位的值在本表不能重覆,可以為null,但只能有一次。關鍵字 unique --預設約束 給該欄位一個預設值,空的時候按預設值來,不空的按你輸入的來 def ...
  • --create database 資料庫名create database Unit2_1712A --使用資料庫【打開資料庫】--use 資料庫名use Unit2_1712A --刪除資料庫--drop database 資料庫名drop database Unit2_1712A --備份資料庫 ...
一周排行
    -Advertisement-
    Play Games
  • 基於.NET Framework 4.8 開發的深度學習模型部署測試平臺,提供了YOLO框架的主流系列模型,包括YOLOv8~v9,以及其系列下的Det、Seg、Pose、Obb、Cls等應用場景,同時支持圖像與視頻檢測。模型部署引擎使用的是OpenVINO™、TensorRT、ONNX runti... ...
  • 十年沉澱,重啟開發之路 十年前,我沉浸在開發的海洋中,每日與代碼為伍,與演算法共舞。那時的我,滿懷激情,對技術的追求近乎狂熱。然而,隨著歲月的流逝,生活的忙碌逐漸占據了我的大部分時間,讓我無暇顧及技術的沉澱與積累。 十年間,我經歷了職業生涯的起伏和變遷。從初出茅廬的菜鳥到逐漸嶄露頭角的開發者,我見證了 ...
  • C# 是一種簡單、現代、面向對象和類型安全的編程語言。.NET 是由 Microsoft 創建的開發平臺,平臺包含了語言規範、工具、運行,支持開發各種應用,如Web、移動、桌面等。.NET框架有多個實現,如.NET Framework、.NET Core(及後續的.NET 5+版本),以及社區版本M... ...
  • 前言 本文介紹瞭如何使用三菱提供的MX Component插件實現對三菱PLC軟元件數據的讀寫,記錄了使用電腦模擬,模擬PLC,直至完成測試的詳細流程,並重點介紹了在這個過程中的易錯點,供參考。 用到的軟體: 1. PLC開發編程環境GX Works2,GX Works2下載鏈接 https:// ...
  • 前言 整理這個官方翻譯的系列,原因是網上大部分的 tomcat 版本比較舊,此版本為 v11 最新的版本。 開源項目 從零手寫實現 tomcat minicat 別稱【嗅虎】心有猛虎,輕嗅薔薇。 系列文章 web server apache tomcat11-01-官方文檔入門介紹 web serv ...
  • 1、jQuery介紹 jQuery是什麼 jQuery是一個快速、簡潔的JavaScript框架,是繼Prototype之後又一個優秀的JavaScript代碼庫(或JavaScript框架)。jQuery設計的宗旨是“write Less,Do More”,即倡導寫更少的代碼,做更多的事情。它封裝 ...
  • 前言 之前的文章把js引擎(aardio封裝庫) 微軟開源的js引擎(ChakraCore))寫好了,這篇文章整點js代碼來測一下bug。測試網站:https://fanyi.youdao.com/index.html#/ 逆向思路 逆向思路可以看有道翻譯js逆向(MD5加密,AES加密)附完整源碼 ...
  • 引言 現代的操作系統(Windows,Linux,Mac OS)等都可以同時打開多個軟體(任務),這些軟體在我們的感知上是同時運行的,例如我們可以一邊瀏覽網頁,一邊聽音樂。而CPU執行代碼同一時間只能執行一條,但即使我們的電腦是單核CPU也可以同時運行多個任務,如下圖所示,這是因為我們的 CPU 的 ...
  • 掌握使用Python進行文本英文統計的基本方法,並瞭解如何進一步優化和擴展這些方法,以應對更複雜的文本分析任務。 ...
  • 背景 Redis多數據源常見的場景: 分區數據處理:當數據量增長時,單個Redis實例可能無法處理所有的數據。通過使用多個Redis數據源,可以將數據分區存儲在不同的實例中,使得數據處理更加高效。 多租戶應用程式:對於多租戶應用程式,每個租戶可以擁有自己的Redis數據源,以確保數據隔離和安全性。 ...