您好,登錄后才能下訂單哦!
本篇內容介紹了“怎么理解iOS開發中的鎖”的有關知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠學有所成!
上述文章中已經介紹了 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 循環,他們都不會在這里卡住,而是繼續執行,這樣就無法保證鎖的可靠性了。解決思路也很簡單,只要確保申請鎖的過程是原子操作即可。
狹義上的原子操作表示一條不可打斷的操作,也就是說線程在執行操作過程中,不會被操作系統掛起,而是一定會執行完。在單處理器環境下,一條匯編指令顯然是原子操作,因為中斷也要通過指令來實現。
然而在多處理器的情況下,能夠被多個處理器同時執行的操作任然算不上原子操作。因此,真正的原子操作必須由硬件提供支持,比如 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 表示 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_NORMAL
、PTHREAD_MUTEX_ERRORCHECK
、PTHREAD_MUTEX_RECURSIVE
等等,具體的特性就不做解釋了,網上有很多相關資料。
一般情況下,一個線程只能申請一次鎖,也只能在獲得鎖的情況下才能釋放鎖,多次申請鎖或釋放未獲得的鎖都會導致崩潰。假設在已經獲得鎖的情況下再次申請鎖,線程會因為等待鎖的釋放而進入睡眠狀態,因此就不可能再釋放鎖,從而導致死鎖。
然而這種情況經常會發生,比如某個函數申請了鎖,在臨界區內又遞歸調用了自己。辛運的是 pthread_mutex
支持遞歸鎖,也就是允許一個線程遞歸的申請鎖,只要把 attr 的類型改成 PTHREAD_MUTEX_RECURSIVE
即可。
互斥鎖在申請鎖時,調用了 pthread_mutex_lock
方法,它在不同的系統上實現各有不同,有時候它的內部是使用信號量來實現,即使不用信號量,也會調用到 lll_futex_wait
函數,從而導致線程休眠。
上文說到如果臨界區很短,忙等的效率也許更高,所以在有些版本的實現中,會首先嘗試一定次數(比如 1000 次)的 testandtest,這樣可以在錯誤使用互斥鎖時提高性能。
另外,由于 pthread_mutex
有多種類型,可以支持遞歸鎖等,因此在申請加鎖時,需要對鎖的類型加以判斷,這也就是為什么它和信號量的實現類似,但效率略低的原因。
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
的底層是通過條件變量(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
其實是封裝了一個互斥鎖和條件變量, 它把前者的 lock
方法和后者的 wait/signal
統一在 NSCondition
對象中,暴露給使用者:
- (void) signal { pthread_cond_signal(&_condition); } // 其實這個函數是通過宏來定義的,展開后就是這樣 - (void) lock { int err = pthread_mutex_lock(&_mutex); } 復制代碼
它的加解鎖過程與 NSLock
幾乎一致,理論上來說耗時也應該一樣(實際測試也是如此)。在圖中顯示它耗時略長,我猜測有可能是測試者在每次加解鎖的前后還附帶了變量的初始化和銷毀操作。
上文已經說過,遞歸鎖也是通過 pthread_mutex_lock
函數來實現,在函數內部會判斷鎖的類型,如果顯示是遞歸鎖,就允許遞歸調用,僅僅將一個計數器加一,鎖的釋放過程也是同理。
NSRecursiveLock
與 NSLock
的區別在于內部封裝的 pthread_mutex_t
對象的類型不同,前者的類型為 PTHREAD_MUTEX_RECURSIVE
。
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]; } 復制代碼
這其實是一個 OC 層面的鎖, 主要是通過犧牲性能換來語法上的簡潔與可讀。
我們知道 @synchronized 后面需要緊跟一個 OC 對象,它實際上是把這個對象當做鎖來使用。這是通過一個哈希表來實現的,OC 在底層使用了一個互斥鎖的數組(你可以理解為鎖池),通過對對象去哈希值來得到對應的互斥鎖。
“怎么理解iOS開發中的鎖”的內容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業相關的知識可以關注億速云網站,小編將為大家輸出更多高質量的實用文章!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。