執行緒安全(二)

騎著jm的hi發表於2018-08-20

原文連結

之前寫過一篇執行緒安全,簡單介紹了保護資料安全的多種方式,以及其中一部分方式的原理。基於此基礎,本文將介紹如何避免鎖的效能浪費,以及如何實現無鎖安全結構

避免鎖的效能浪費

為了避免多個執行緒對資料的破壞,在使用鎖保障執行緒安全的情況下,存在幾個影響鎖效能的重要因素:

  • 上下文切換
  • 臨界區資源耗時

如果能夠減少這些因素的損耗,就能有效的提高鎖的效能

自旋鎖

通常來說,當一個執行緒獲取鎖失敗後,會被新增到一個等待佇列的末尾,然後休眠。直到鎖被釋放後,依次喚醒訪問臨界資源。休眠時會發生執行緒的上下文切換,當前執行緒的暫存器資訊會被儲存到磁碟上,考慮到這些情況,能做的有兩點:

  1. 換一個更快的磁碟
  2. 改用自旋鎖

自旋鎖採用死迴圈等待鎖釋放來替代執行緒的休眠和喚醒,避免了上下文切換的代價。當臨界的程式碼足夠短,使用自旋鎖對於效能的提升是立竿見影的

鎖粒度

粒度是指顆粒的大小

對於鎖來說,鎖的粒度大小取決於鎖保護的臨界區的大小。鎖的粒度越小,臨界區的操作就越小,反之亦然,由於臨界區執行程式碼時間導致的損耗問題我稱作粒度鎖問題。舉個例子,假如某個修改元素的方法包括三個操作:查詢快取->查詢容器->修改元素

- (void)modifyName: (NSString *)name withId: (id)usrId {
    lock();
    User *usr = [self.caches findUsr: usrId];
    if (!usr) {
        usr = [self.collection findUsr: usrId];
    }
    if (!usr) {
        unlock();
        return;
    }
    usr.name = name;
    unlock();
}
複製程式碼

實際上整個修改操作之中,只有最後的修改元素存在安全問題需要加鎖,並且如果加鎖的臨界區程式碼執行時間過長可能導致有更多的執行緒需要等待鎖,增加了鎖使用的損耗。因此加鎖程式碼應當儘量的短小簡單:

- (void)modifyName: (NSString *)name withId: (id)usrId {
    User *usr = [self.caches findUsr: usrId];
    if (!usr) {
        usr = [self.collection findUsr: usrId];
    }
    if (!usr) {
        return;
    }
    
    lock();
    usr.name = name;
    unlock();
}
複製程式碼

大段程式碼改為小段程式碼加鎖是一種常見的減少鎖效能損耗的做法,因此不再多提。但接下來要說的是另一種常見但因為鎖粒度造成損耗的問題:設想一下這個場景,在改良後的程式碼使用中,執行緒A對第三個元素進行修改,執行緒B對第四個元素進行修改:

執行緒安全(二)

在兩個執行緒修改user的過程中,實際上雙方的操作是不衝突,但是執行緒B必須等待A完成修改工作,造成這個現象的原因是雖然看起來是對usr.name進行了加鎖,但實際上是鎖住了collectioncaches的操作,所以避免這種隱藏的粒度鎖問題的方案是以容器元素單位構建鎖:包括全域性鎖獨立鎖兩種:

  • 全域性鎖

    構建一個global lock的集合,用hash的手段為修改元素對應一個鎖:

      id l = [SLGlobalLocks getLock: hash(usr)];
      lock(l);
      usr.name = name;
      unlock(l);
    複製程式碼

    使用全域性鎖的好處包括可以在設計上可以懶載入生成鎖,限制bucketCount來避免建立過多的鎖例項,基於hash的對映關係,鎖可以被多個物件獲取,提高複用率。但缺點也是明顯的,匹配鎖的額外損耗,hash對映可能導致多個鎖圍觀一個鎖工作等。事實上@synchronized就是已存在的全域性鎖方案

  • 獨立鎖

    這個方案的名字是隨便起的,從設計上要求容器的每個元素擁有自己的獨立鎖:

      @interface SLLockItem: NSObject
      
      @property (nonatomic, strong) id item;
      @property (nonatomic, strong) NSLock *lock;
      
      @end
      
      SLLockItem *item = [self.collection findUser: usrId];
      [item.lock lock];
      User *usr = item.item;
      usr.name = name;
      [item.lock unlock];
    複製程式碼

    獨立鎖保證了不同元素之間的加鎖是絕對獨立的,粒度完全可控,但鎖難以複用,容器越長,需要建立的鎖例項就越多也是致命的缺點。並且在鏈式結構中,增刪操作的加鎖是要和previous節點的修改操作發生競爭的,在實現上更加麻煩

無鎖安全結構

無鎖化是完全拋棄加鎖工具,實現多執行緒訪問安全的方案。無鎖化需要去分解操作流程,找出真正需要保證安全的操作,舉個例子:存在連結串列A -> B -> C,刪除B的程式碼如下:

Node *cur = list;
while (cur.next != B && cur.next != nil) {
    cur = cur.next;
}

if (cur.next == nil) {
    return;
}

cur.next = cur.next.next;
複製程式碼

執行緒安全(二)

只要A.next的修改是不受多執行緒干擾的,那麼就能保證刪除元素的安全

CAS

compare and swap是計算機硬體提供的一種原子操作,它會比較兩個值是否相等,然後決定下一步的執行指令,iOS對於這種操作的支援需要匯入<libkern/OSAtomic.h>檔案。

bool	OSAtomicCompareAndSwapPtrBarrier( void *oldVal, void *newVal, void * volatile *theVal )
複製程式碼

函式會在oldValtheVal相同的情況下將oldVal儲存的值修改為newVal,因此刪除B的程式碼只要保證在A.next變成nil之前是一致的就可以保證安全:

Node *cur = list;
while (cur.next != B && cur.next != nil) {
    cur = cur.next;
}

if (cur.next == nil) {
    return;
}

while (true) {
    void *oldValue = cur.next;
    if (OSAtomicCompareAndSwapPtrBarrier(oldValue, nil, &cur.next)) {
        break;
    } else {
        continue;
    } 
}
複製程式碼

基於上面刪除B的例子,同一時間存在其他執行緒在A節點後追加D節點:

執行緒安全(二)

由於CPU可能在任務執行過程中切換執行緒,如果D節點的修改工作正好在刪除任務的中間完成,最終可能導致的是D節點的誤刪:

執行緒安全(二)

所以上面的CAS還需要考慮A.next是否發生了改變:

Node *cur = list;
while (cur.next.value != B && cur.next != nil) {
    cur = cur.next;
}

if (cur.next == nil) {
    return;
}

while (true) {
    void *oldValue = cur.next;
    
    // next已經不再指向B
    if (!OSAtomicCompareAndSwapPtrBarrier(B, B, &cur.next.value)) {
        break;
    }
    
    if (OSAtomicCompareAndSwapPtrBarrier(oldValue, nil, &cur.next)) {
        break;
    } else {
        continue;
    } 
}
複製程式碼

題外話

OSAtomicCompareAndSwapPtrBarrier除了保證修改操作的原子性,還帶有barrier的作用。在現在CPU的設計上,會考慮打亂程式碼的執行順序來獲取更快的執行速度,比如說:

/// 執行緒1執行
A.next = D;
D.next = C;

/// 執行緒2執行
while (D.next != C) {}
NSLog(@"%@", A.next);
複製程式碼

由於執行順序會被打亂,執行的時候變成:

D.next = C;
A.next = D;

while (D.next != C) {}
NSLog(@"%@", A.next);
複製程式碼

輸出的結果可能並不是D,而只要在D.next = C前面插入一句barrier函式,就能保證在這句程式碼前的指令不會被打亂執行,保證正確的程式碼順序

最後

很方,這個月想了很多想寫的內容,然後發現別人都寫過,尷尬的一筆。果然還是自己太鶸了,最後隨便趕工了一篇全是水貨的文章,瑟瑟發抖

關注我的公眾號獲取更新資訊

相關文章