關于“鎖”的一些事兒

多線程在日常開發中會時不時遇到。首先APP會有一個主線程(UI線程),處理一些UI相關的邏輯。但是牽扯到網絡、數據庫等耗時的操作需要新開辟線程處理,避免“卡住”主線程,給用戶留下不好的印象。多線程的好處不言而喻:幕后做事,不影響明面上的事兒。但是也有一些需要注意的地方,其中“資源搶奪”就是需要特別注意的一點。

資源搶奪

所謂資源搶奪就是多個線程同時操作一個數據。

下面這段代碼很簡單,就是往Preferences文件中存一個值,并讀取出來輸出

    override func viewDidLoad() {
        super.viewDidLoad()

        // 寫
        saveData(key: identifier1, value: 1)
        // 讀
        let result1 = readData(key: identifier1)
        print(" result1: \(String(describing: result1))")
        
        // 寫
        saveData(key: identifier2, value: 2)
        // 讀
        print("result2: \(String(describing: result1))")
    }

輸出結果毫無疑問是
result1: 1
result2: 2

如果這么寫

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // 線程一操作
        let queue1 = DispatchQueue(label: "queue1");
        queue1.async {[weak self] in
            // 寫
            self?.saveData(key: identifier, value: 1)
            // 讀
            let result = self?.readData(key: identifier) ?? ""
            print("queue1 result: \(String(describing: result))")
        }
        
        // 線程二操作
        let queue2 = DispatchQueue(label: "queue2");
        queue2.async {[weak self] in
            // 寫
            self?.saveData(key: identifier, value: 2)
            // 讀
            let result = self?.readData(key: identifier) ?? ""
            print("queue2 result: \(String(describing: result))")
        }
    }

通常會認為 queue1 先輸出 1, 然后 queue2 再輸出 2。 但實際上...
循環打印的結果
queue1 result: 1
queue2 result: 2
queue2 result: 1
queue2 result: 2
queue1 result: 2
queue2 result: 2
queue2 result: 2
queue1 result: 1

剛才代碼中的 queue1要讀取并寫入, 但很有可能 queue2 這時候也運行了, 它在 queue1 的寫入操作沒有完成之前就做了讀取操作。 這時候他們兩個讀到值都是0, 就會造成兩個都輸出1。線程的調度是由操作系統來控制的,如果 queue2 調用的時, queue1 正好寫入完成,這時就能得到正確的輸出結果。 可如果 queue2 調起的時候 queue1 還沒寫入完成,那么就會出現輸出同樣結果的現象。 這一切都是由操作系統來控制。

解決

1、NSLock

NSLock 是 iOS 提供給我們的一個 API 封裝, 可以很好的解決資源搶奪問題。 NSLock 就是對線程加鎖機制的一個封裝
使用示例:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let lock = NSLock()
        
        for _ in 0..<100 {
            // 線程一操作
            let queue1 = DispatchQueue(label: "queue1");
            queue1.async {[weak self] in
                lock.lock() // 鎖起來
                // 寫
                self?.saveData(key: identifier, value: 1)
                
                // 讀
                let result = self?.readData(key: identifier) ?? ""
                lock.unlock()  // 解鎖
                
                print("queue1 result: \(String(describing: result))")
            }
            
            // 線程二操作
            let queue2 = DispatchQueue(label: "queue2");
            queue2.async {[weak self] in
                lock.lock() // 鎖起來
                // 寫
                self?.saveData(key: identifier, value: 2)
                
                // 讀
                let result = self?.readData(key: identifier) ?? ""
                lock.unlock()  // 解鎖
                
                print("queue2 result: \(String(describing: result))")
            }
        }
    }

循環打印的結果
queue1 result: 1
queue2 result: 2
queue1 result: 1
queue2 result: 2
queue1 result: 2
queue2 result: 2
queue1 result: 1
queue2 result: 2

互斥鎖(pthread_mutex_lock)

從實現原理上來講,Mutex(互斥鎖)屬于sleep-waiting類型的鎖。例如在一個多核的機器上有兩個線程p1和p2,分別運行在Core1和 Core2上。假設線程p1想要通過pthread_mutex_lock操作去得到一個臨界區(Critical Section)的鎖,而此時這個鎖正被線程p2所持有,那么線程p1就會被阻塞 (blocking),Core1 會在此時進行上下文切換(Context Switch)將線程p1置于等待隊列中,此時Core1就可以運行其他的任務(例如另一個線程p3),而不必進行忙等待。

自旋鎖(Spin lock)

先插個話題:在OC中定義屬性時,很多人會認為如果屬性具備 nonatomic 特質,則不使用 “同步鎖”。其實在屬性設置方法中使用的是自旋鎖。

旋鎖與互斥鎖有點類似,只是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執行單元保持,調用者就一直循環在那里看是 否該自旋鎖的保持者已經釋放了鎖,"自旋"一詞就是因此而得名。其作用是為了解決某項資源的互斥使用。因為自旋鎖不會引起調用者睡眠,所以自旋鎖的效率遠 高于互斥鎖。

雖然它的效率比互斥鎖高,但是它也有些不足之處:

1、自旋鎖一直占用CPU,他在未獲得鎖的情況下,一直運行--自旋,所以占用著CPU,如果不能在很短的時 間內獲得鎖,這無疑會使CPU效率降低。
2、在用自旋鎖時有可能造成死鎖,當遞歸調用時有可能造成死鎖,調用有些其他函數也可能造成死鎖,如 copy_to_user()、copy_from_user()、kmalloc()等。
因此我們要慎重使用自旋鎖,自旋鎖只有在內核可搶占式或SMP的情況下才真正需要,在單CPU且不可搶占式的內核下,自旋鎖的操作為空操作。自旋鎖適用于鎖使用者保持鎖時間比較短的情況下。

總結

這里貼一張ibireme做的測試圖,介紹了一些iOS 中的鎖的API,及其效率


674591-176434d65ad6f5b6.png

挑幾個我們常用且熟悉的啰嗦幾句

@synchronized (屬:互斥鎖)

顯然,這是我們最熟悉的加鎖方式,因為這是OC層面的為我們封裝的,使用起來簡單粗暴。使用時 @synchronized 后面需要緊跟一個 OC 對象,它實際上是把這個對象當做鎖來使用。這是通過一個哈希表來實現的,OC 在底層使用了一個互斥鎖的數組(也就是鎖池),通過對象的哈希值來得到對應的互斥鎖。

-(void)criticalMethod  
{  
    @synchronized(self)  
    {  
        //關鍵代碼;  
    }  
}  
NSLock(屬:互斥鎖)

NSLock 是OC 以對象的形式暴露給開發者的一種鎖,它的實現非常簡單,通過宏,定義了 lock 方法:
#define MLOCK - (void) lock{\ int err = pthread_mutex_lock(&_mutex);\ // 錯誤處理 ……}

NSLock只是在內部封裝了一個pthread_mutex,屬性為PTHREAD_MUTEX_ERRORCHECK,它會損失一定性能換來錯誤提示。這里使用宏定義的原因是,OC 內部還有其他幾種鎖,他們的 lock 方法都是一模一樣,僅僅是內部pthread_mutex互斥鎖的類型不同。通過宏定義,可以簡化方法的定義。NSLock比pthread_mutex略慢的原因在于它需要經過方法調用,同時由于緩存的存在,多次方法調用不會對性能產生太大的影響。

atomic原子操作(屬:自旋鎖)

即不可分割開的操作;該操作一定是在同一個cpu時間片中完成,這樣即使線程被切換,多個線程也不會看到同一塊內存中不完整的數據。如果屬性具備 atomic 特質,則在屬性設置方法中使用的是“自旋鎖”。

什么情況下用什么鎖?

1、總的來看,推薦pthread_mutex作為實際項目的首選方案;
2、對于耗時較大又易沖突的讀操作,可以使用讀寫鎖代替pthread_mutex;
3、如果確認僅有set/get的訪問操作,可以選用原子操作屬性;
4、對于性能要求苛刻,可以考慮使用OSSpinLock,需要確保加鎖片段的耗時足夠小;
5、條件鎖基本上使用面向對象的NSCondition和NSConditionLock即可;
6、@synchronized則適用于低頻場景如初始化或者緊急修復使用;

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 引用自多線程編程指南應用程序里面多個線程的存在引發了多個執行線程安全訪問資源的潛在問題。兩個線程同時修改同一資源有...
    Mitchell閱讀 2,020評論 1 7
  • iOS線程安全的鎖與性能對比 一、鎖的基本使用方法 1.1、@synchronized 這是我們最熟悉的枷鎖方式,...
    Jacky_Yang閱讀 2,263評論 0 17
  • 前言 iOS開發中由于各種第三方庫的高度封裝,對鎖的使用很少,剛好之前面試中被問到的關于并發編程鎖的問題,都是一知...
    喵渣渣閱讀 3,730評論 0 33
  • 鎖是一種同步機制,用于多線程環境中對資源訪問的限制iOS中常見鎖的性能對比圖(摘自:ibireme): iOS鎖的...
    LiLS閱讀 1,553評論 0 6
  • 教授講,不是認識字的人,都能教語文!這是對自認為誰都能教語文,評價語文課的人錯誤認識的糾正,同時也是對語文教師提了...
    小水月閱讀 466評論 1 8