構建一個 @synchronized

SwiftGG翻譯組發表於2018-07-31

原文連結:https://swift.gg/2018/07/30/friday-qa-2015-02-20-lets-build-synchronized/
作者:Mike Ash
譯者:Sunnyyoung
校對:智多芯
定稿:numbbbbbCMB

上一篇文章講了執行緒安全,今天這篇最新一期的 Let's Build 我會探討一下如何實現 Objective-C 中的 @synchronized。本文基於 Swift 實現,Objective-C 版本大體上也差不多。

回顧

@synchronized 在 Objective-C 中是一種控制結構。它接受一個物件指標作為引數,後面跟著一段程式碼塊。物件指標充當鎖,在任何時候 @synchronized 程式碼塊中只允許有一個執行緒使用該物件指標。

這是一種使用鎖進行多執行緒程式設計的簡單方法。舉個例子,你可以使用 NSLock 來保護對 NSMutableArray 的操作:

NSMutableArray *array;
NSLock *arrayLock;

[arrayLock lock];
[array addObject: obj];
[arrayLock unlock];
複製程式碼

也可以使用 @synchronized 來將陣列本身加鎖:

@synchronized(array) {
    [array addObject: obj];
}
複製程式碼

我個人更喜歡顯式的鎖,這樣做既可以使事情更清楚,@synchronized 的效能沒那麼好,原因如下圖所示。但它(@synchronized)使用很方便,不管怎樣,實現起來都很有意思。

原理

Swift 版本的 @synchronized 是一個函式。它接受一個物件和一個閉包,並使用持有的鎖呼叫閉包:

func synchronized(obj: AnyObject, f: Void -> Void) {
    ...
}
複製程式碼

問題是,如何將任意物件變成鎖?

在一個理想的世界裡(從實現這個函式的角度來看),每個物件都會為鎖留出一些額外空間。在這個額外的小空間裡 synchronized 可以使用適當的 lockunlock 方法。然而實際上並沒有這種額外空間。這可能是件好事,因為這會增大物件佔用的記憶體空間,但是大多數物件永遠都不會用到這個特性。

另一種方法是用一張表來記錄物件到鎖的對映。synchronized 可以查詢表中的鎖,然後執行 lockunlock 操作。這種方法的問題是表本身需要保證執行緒安全,它要麼需要自己的鎖,要麼需要某種特殊的無鎖資料結構。為表單獨設定一個鎖要容易得多。

為了防止鎖不斷累積常駐,表需要跟蹤鎖的使用情況,並在不再需要鎖的時候銷燬或者複用。

實現

要實現將物件對映到鎖的表,NSMapTable 非常合適。它可以把原始物件的地址設定成鍵(key),並且可以儲存對鍵(key)和值(value)的弱引用,從而允許系統自動回收未被使用的鎖。

let locksTable = NSMapTable.weakToWeakObjectsMapTable()
複製程式碼

表中儲存的物件是 NSRecursiveLock 例項。因為它是一個類,所以可以直接用在 NSMapTable 中,這點 pthread_mutex_t 就做不到。@synchronized 支援遞迴語義,我們的實現一樣支援。

表本身也需要一個鎖。自旋鎖(spinlock)在這種情況下很適合使用,因為對錶的訪問是短暫的:

var locksTableLock = OS_SPINLOCK_INIT
複製程式碼

有了這個表,我們就可以實現以下方法:

func synchronized(obj: AnyObject, f: Void -> Void) {
複製程式碼

它所做的第一件事就是在 locksTable 中找出與 obj 對應的鎖,執行操作之前必須持有 locksTableLock 鎖:

OSSpinLockLock(&locksTableLock)
var lock = locksTable.objectForKey(obj) as! NSRecursiveLock?
複製程式碼

如果表中沒有找到對應鎖,則建立一個新鎖並儲存到表中:

if lock == nil {
    lock = NSRecursiveLock()
    locksTable.setObject(lock!, forKey: obj)
}
複製程式碼

有了鎖之後主表鎖就可以釋放了。為了避免死鎖這必須要在呼叫 f 之前完成:

OSSpinLockUnlock(&locksTableLock)
複製程式碼

現在我們可以呼叫 f 了,在呼叫前後分別進行加鎖和解鎖操作:

    lock!.lock()
    f()
    lock!.unlock()
}
複製程式碼

對比蘋果的方案

蘋果實現 @synchronized 的方案可以在 Objective-C runtime 原始碼中找到:

http://www.opensource.apple.com/source/objc4/objc4-646/runtime/objc-sync.mm

它的主要目標是效能,因此不像上面那個玩具般的例子那麼簡單。對比它們之間有什麼異同是一件非常有趣的事。

基本概念是相同的。存在一個全域性表,它將物件指標對映到鎖,然後該鎖在 @synchronized 程式碼塊前後進行加鎖解鎖操作。

對於底層的鎖物件,Apple 使用配置為遞迴鎖的 pthread_mutex_tNSRecursiveLock 內部很可能也使用了 pthread_mutex_t,直接使用就省去了中間環節,並避免了執行時對 Foundation 的依賴。

表本身的實現是一個連結串列而不是一個雜湊表。常見的情況是在任何給定的時間裡只存在少數幾個鎖,所以連結串列的效能表現很不錯,可能比雜湊表效能更好。每個執行緒快取了最近在當前執行緒查詢的鎖,從而進一步提高效能。

蘋果的實現並不是只有一個全域性表,而是在一個陣列裡儲存了 16 個表。物件根據地址對映到不同的表,這減少了不同物件 @synchronized 操作導致的不必要的資源競爭,因為它們很可能使用的是兩個不同的全域性表。

蘋果的實現沒有使用弱指標引用(這會大量增加額外開銷),而是為每個鎖保留一個內部的引用計數。當引用計數達到零時,該鎖可以給新物件重新使用。未使用的鎖不會被銷燬,但複用意味著在任何時間鎖的總數都不能超過啟用鎖的數量,也就是說鎖的數量不隨著新物件的建立無限制增長。

蘋果的實現方案非常巧妙,效能也不錯。但與使用單獨的顯式鎖相比,它仍然會帶來一些不可避免的額外開銷。尤其是:

  1. 如果不相關的物件剛好被分配到同一個全域性表中,那麼它們仍然可能存在資源競爭。
  2. 通常情況下線上程快取中查詢一個不存在的鎖時,必須獲取並釋放一個自旋鎖。
  3. 必須做更多的工作來查詢全域性表中物件的鎖。
  4. 即使不需要,每個加鎖/解鎖週期都會產生遞迴語義方面的開銷。

結論

@synchronized 是一個有趣的語言結構,實現起來並不簡單。它的作用是實現執行緒安全,但它的實現本身也需要同步操作來保證執行緒安全。我們使用全域性鎖來保護對鎖表的訪問,蘋果的實現中則使用不同的技巧來提高效能。

相關文章