因為原文一些內容寫的不太準確,我按照我的理解做出了批註和補充。
如果你已經使用 Objective-C 編寫過任何併發程式,那麼想必是見過 @synchronized 這貨了。@synchronized 結構所做的事情跟鎖(lock)類似:它防止不同的執行緒同時執行同一段程式碼。但在某些情況下,相比於使用 NSLock 建立鎖物件、加鎖和解鎖來說,@synchronized 用著更方便,可讀性更高。
譯者注:這與蘋果官方文件對 @synchronized 的介紹有少許出入,但意思差不多。蘋果官方文件更強調它“防止不同的執行緒同時獲取相同的鎖”,因為文件在集中介紹多執行緒程式設計各種鎖的作用,所以更強調“相同的鎖”而不是“同一段程式碼”。
如果你之前沒用過 @synchronized,接下來有個使用它的例子。這篇文章實質上是談談有關我對 @synchronized 實現原理的一個簡短研究。
用到 @synchronized 的例子
假設我們正在用 Objective-C 實現一個執行緒安全的佇列,我們一開始可能會這麼幹:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@implementation ThreadSafeQueue { NSMutableArray *_elements; NSLock *_lock; } - (instancetype)init { self = [super init]; if (self) { _elements = [NSMutableArray array]; _lock = [[NSLock alloc] init]; } return self; } - (void)push:(id)element { [_lock lock]; [_elements addObject:element]; [_lock unlock]; } @end |
上面的 ThreadSafeQueue 類有個 init 方法,它初始化了一個 _elements 陣列和一個 NSLock 例項。這個類還有個 push: 方法,它先獲取鎖、然後向陣列中插入元素、最終釋放鎖。可能會有許多執行緒同時呼叫 push: 方法,但是 [_elements addObject:element] 這行程式碼在任何時候將只會在一個執行緒上執行。步驟如下:
- 執行緒 A 呼叫 push: 方法
- 執行緒 B 呼叫 push: 方法
- 執行緒 B 呼叫 [_lock lock] – 因為當前沒有其他執行緒持有鎖,執行緒 B 獲得了鎖
- 執行緒 A 呼叫 [_lock lock],但是鎖已經被執行緒 B 佔了所以方法呼叫並沒有返回-這會暫停執行緒 A 的執行
- 執行緒 B 向 _elements 新增元素後呼叫 [_lock unlock]。當這些發生時,執行緒 A 的 [_lock lock] 方法返回,並繼續將自己的元素插入 _elements。
我們可以用 @synchronized 結構更簡要地實現這些:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@implementation ThreadSafeQueue { NSMutableArray *_elements; } - (instancetype)init { self = [super init]; if (self) { _elements = [NSMutableArray array]; } return self; } - (void)increment { @synchronized (self) { [_elements addObject:element]; } } @end |
在前面的例子中,”synchronized block” 與 [_lock lock] 和 [_lock unlock] 效果相同。你可以把它當成是鎖住 self,彷彿 self 就是個 NSLock。鎖在左括號 { 後面的任何程式碼執行之前被獲取到,在右括號 } 後面的任何程式碼執行之前被釋放掉。這爽就爽在媽媽再也不用擔心我忘記呼叫 unlock 了!
你可以給任何 Objective-C 物件上加個 @synchronized。那麼我們也可以在上面的例子中用 @synchronized(_elements) 來替代 @synchronized(self),效果是相同的。
回到研究上來
我對 @synchronized 的實現十分好奇並搜了一些它的細節。我找到了一些答案,但這些解釋都沒有達到我想要的深度。鎖是如何與你傳入 @synchronized 的物件關聯上的?@synchronized會保持(retain,增加引用計數)被鎖住的物件麼?假如你傳入 @synchronized 的物件在 @synchronized 的 block 裡面被釋放或者被賦值為 nil 將會怎麼樣?這些全都是我想回答的問題。而我這次的收穫,會要你好看
@synchronized 的文件告訴我們 @synchronized block 在被保護的程式碼上暗中新增了一個異常處理。為的是同步某物件時如若丟擲異常,鎖會被釋放掉。
SO 上的這篇帖子 說 @synchronized block 會變成 objc_sync_enter 和 objc_sync_exit 的成對兒呼叫。我們不知道這些函式是幹啥的,但基於這些事實我們可以認為編譯器將這樣的程式碼:
1 2 3 |
@synchronized(obj) { // do work } |
轉化成這樣的東東:
1 2 3 4 5 6 |
<a href='http://www.jobbole.com/members/xyz937134366'>@try</a> { objc_sync_enter(obj); // do work } <a href='http://www.jobbole.com/members/finally'>@finally</a> { objc_sync_exit(obj); } |
objc_sync_enter 和 objc_sync_exit 是什麼鬼?它們是如何實現的?在 Xcode 中按住 Command 鍵單擊它們,然後進到了 <objc/objc-sync.h>,裡面有我們感興趣的這兩個函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
/** * Begin synchronizing on 'obj'. * Allocates recursive pthread_mutex associated with 'obj' if needed. * * @param obj The object to begin synchronizing on. * * @return OBJC_SYNC_SUCCESS once lock is acquired. */ OBJC_EXPORT int objc_sync_enter(id obj) __OSX_AVAILABLE_STARTING(__MAC_10_3, __IPHONE_2_0); /** * End synchronizing on 'obj'. * * @param obj The objet to end synchronizing on. * * @return OBJC_SYNC_SUCCESS or OBJC_SYNC_NOT_OWNING_THREAD_ERROR */ OBJC_EXPORT int objc_sync_exit(id obj) __OSX_AVAILABLE_STARTING(__MAC_10_3, __IPHONE_2_0); |
檔案底部的一句話提醒著我們:蘋果工程師也是人啊哈哈
1 2 3 4 5 6 7 |
// The wait/notify functions have never worked correctly and no longer exist. OBJC_EXPORT int objc_sync_wait(id obj, long long milliSecondsMaxWait) UNAVAILABLE_ATTRIBUTE; OBJC_EXPORT int objc_sync_notify(id obj) UNAVAILABLE_ATTRIBUTE; OBJC_EXPORT int objc_sync_notifyAll(id obj) UNAVAILABLE_ATTRIBUTE; |
譯者注: 此處原文摘抄的原始碼較舊,所以我替換上了最新的標頭檔案原始碼。
不過,objc_sync_enter 的文件告訴我們一些新東西: @synchronized 結構在工作時為傳入的物件分配了一個遞迴鎖。分配工作何時發生,如何發生呢?它怎樣處理 nil?幸運的是 Objective-C runtime 是開源的,所以我們可以馬上閱讀原始碼並找到答案!
注:遞迴鎖在被同一執行緒重複獲取時不會產生死鎖。你可以在這找到一個它工作原理的精巧案例。有個叫做 NSRecursiveLock 的現成的類也是這樣的,你可以試試。
你可以在這裡找到 objc-sync 的全部原始碼,但我要帶著你看原始碼,讓你屌的飛起。我們先從檔案頂部的資料結構開始看。在程式碼塊的下方我將立刻做出解釋,所以嘗試理解程式碼時別花太長時間哦。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
typedef struct SyncData { id object; recursive_mutex_t mutex; struct SyncData* nextData; int threadCount; } SyncData; typedef struct SyncList { SyncData *data; spinlock_t lock; } SyncList; // Use multiple parallel lists to decrease contention among unrelated objects. #define COUNT 16 #define HASH(obj) ((((uintptr_t)(obj)) >> 5) & (COUNT - 1)) #define LOCK_FOR_OBJ(obj) sDataLists[HASH(obj)].lock #define LIST_FOR_OBJ(obj) sDataLists[HASH(obj)].data static SyncList sDataLists[COUNT]; |
一開始,我們有一個 struct SyncData 的定義。這個結構體包含一個 object(嗯就是我們給 @synchronized 傳入的那個物件)和一個有關聯的 recursive_mutex_t,它就是那個跟 object 關聯在一起的鎖。每個 SyncData 也包含一個指向另一個 SyncData 物件的指標,叫做 nextData,所以你可以把每個 SyncData 結構體看做是連結串列中的一個元素。最後,每個 SyncData 包含一個 threadCount,這個 SyncData 物件中的鎖會被一些執行緒使用或等待,threadCount 就是此時這些執行緒的數量。它很有用處,因為 SyncData 結構體會被快取,threadCount==0 就暗示了這個 SyncData 例項可以被複用。
下面是 struct SyncList 的定義。正如我在上面提過,你可以把 SyncData 當做是連結串列中的節點。每個 SyncList 結構體都有個指向 SyncData 節點連結串列頭部的指標,也有一個用於防止多個執行緒對此列表做併發修改的鎖。
上面程式碼塊的最後一行是 sDataLists 的宣告 – 一個 SyncList 結構體陣列,大小為16。通過定義的一個雜湊演算法將傳入物件對映到陣列上的一個下標。值得注意的是這個雜湊演算法設計的很巧妙,是將物件指標在記憶體的地址轉化為無符號整型並右移五位,再跟 0xF 做按位與運算,這樣結果不會超出陣列大小。 LOCK_FOR_OBJ(obj) 和 LIST_FOR_OBJ(obj) 這倆巨集就更好理解了,先是雜湊出物件的陣列下標,然後取出陣列對應元素的 lock 或 data。一切都是這麼順理成章哈。
當你呼叫 objc_sync_enter(obj) 時,它用 obj 記憶體地址的雜湊值查詢合適的 SyncData,然後將其上鎖。當你呼叫 objc_sync_exit(obj) 時,它查詢合適的 SyncData 並將其解鎖。
譯者注:上面的原始碼和幾段解釋有些原文解釋不清和疏漏的地方,我看了原始碼後按照自己的理解進行了補充和修正。
噢耶!現在我們知道了 @synchronized 如何將一個鎖和你正在同步的物件關聯起來,我希望聊聊當一個物件在 @synchronized block 當中被釋放或設為 nil 時會發生什麼。
如果你看了原始碼,你會注意到 objc_sync_enter 裡面沒有 retain 和 release。所以它要麼沒有保持傳遞給它的物件,要麼或是在 ARC 下被編譯。我們可以用下面的程式碼來做個測試:
1 2 3 4 5 6 7 8 9 10 |
NSDate *test = [NSDate date]; // This should always be `1` NSLog(@"%@", @([test retainCount])); @synchronized (test) { // This will be `2` if `@synchronized` somehow // retains `test` NSLog(@"%@", @([test retainCount])); } |
兩次輸出結果都是 1。那麼 objc_sync_enter 貌似是沒保持被傳入的物件啊。這就有趣了。如果你正在同步的物件被釋放了,然後有可能另一個新的物件在此處(被釋放物件的記憶體地址)被分配記憶體。有可能某個其他的執行緒試著去同步那個新的物件(就是那個在被釋放的舊物件的記憶體地址上剛剛新建立的物件)。在這種情況下,另一個執行緒將會阻塞,直到當前執行緒結束它的同步 block。這看起來並不是很糟。這聽起來像是這種事情實現者早就知道並予以接受。我沒有遇到過任何好的替代方案。
假如物件在 “synchronized block” 中被設成 nil 呢?我們回顧下我們“拿衣服(naive)”的實現吧:
1 2 3 4 5 6 7 8 9 10 |
NSString *test = @"test"; <a href='http://www.jobbole.com/members/xyz937134366'>@try</a> { // Allocates a lock for test and locks it objc_sync_enter(test); test = nil; } <a href='http://www.jobbole.com/members/finally'>@finally</a> { // Passed `nil`, so the lock allocated in `objc_sync_enter` // above is never unlocked or deallocated objc_sync_exit(test); } |
objc_sync_enter 被呼叫時傳入的是 test 而 objc_sync_exit 被呼叫時傳入的是 nil。而傳入 nil 的時候 objc_sync_exit 是個空操作,所以將不會有人釋放鎖。這真操蛋!
如果 Objective-C 容易受這種情況的影響,我們知道麼?下面的程式碼呼叫 @synchronized 並在 @synchronized block 中將一個指標設為 nil。然後在後臺執行緒對指向同一個物件的指標呼叫 @synchronized。如果在 @synchronized block 中設定一個物件為 nil 會讓鎖死鎖,那麼在第二個 @synchronized 中的程式碼將永遠不會執行。我們將不會在控制檯中看見任何東西列印出來。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
NSNumber *number = @(1); NSNumber *thisPtrWillGoToNil = number; @synchronized (thisPtrWillGoToNil) { /** * Here we set the thing that we're synchronizing on to `nil`. If * implemented naively, the object would be passed to `objc_sync_enter` * and `nil` would be passed to `objc_sync_exit`, causing a lock to * never be released. */ thisPtrWillGoToNil = nil; } dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^ { NSCAssert(![NSThread isMainThread], @"Must be run on background thread"); /** * If, as mentioned in the comment above, the synchronized lock is never * released, then we expect to wait forever below as we try to acquire * the lock associated with `number`. * * This doesn't happen, so we conclude that `@synchronized` must deal * with this correctly. */ @synchronized (number) { NSLog(@"This line does indeed get printed to stdout"); } }); |
當我們執行上面的程式碼時,那行程式碼確實列印到控制檯了!所以 Objective-C 很好地處理了這種情形。我打賭是編譯器做了類似下面的事情來解決這事兒的。
1 2 3 4 5 6 7 8 |
NSString *test = @"test"; id synchronizeTarget = (id)test; <a href='http://www.jobbole.com/members/xyz937134366'>@try</a> { objc_sync_enter(synchronizeTarget); test = nil; } <a href='http://www.jobbole.com/members/finally'>@finally</a> { objc_sync_exit(synchronizeTarget); } |
用這種方式實現的話,傳遞給 objc_sync_enter 和 objc_sync_exit 總是相同的物件。他們在傳入 nil 時都是空操作。這帶來了個棘手的 debug 場景:如果你向 @synchronized 傳遞 nil,那麼你就不會得到任何鎖而且你的程式碼將不會是執行緒安全的!如果你想知道為什麼你正收到出乎意料的競態(race),確保你沒向你的 @synchronized 傳入 nil。你可以在 objc_sync_nil 上設定一個符號斷點來達到此目的。objc_sync_nil 是一個空方法,當 objc_sync_enter 函式被傳入 nil 時會被呼叫,折讓 debug 更容易些。
譯者注:下面是 objc_sync_enter 的原始碼,主要邏輯很容易看懂,加深理解 objc_sync_nil:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
int objc_sync_enter(id obj) { int result = OBJC_SYNC_SUCCESS; if (obj) { SyncData* data = id2data(obj, ACQUIRE); require_action_string(data != NULL, done, result = OBJC_SYNC_NOT_INITIALIZED, "id2data failed"); result = recursive_mutex_lock(&data->mutex); require_noerr_string(result, done, "mutex_lock failed"); } else { // @synchronized(nil) does nothing if (DebugNilSync) { _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug"); } objc_sync_nil(); } done: return result; } |
這回答了我眼下的問題。
- 你呼叫 sychronized 的每個物件,Objective-C runtime 都會為其分配一個遞迴鎖並儲存在雜湊表中。
- 如果在 sychronized 內部物件被釋放或被設為 nil 看起來都 OK。不過這沒在文件中說明,所以我不會再生產程式碼中依賴這條。
- 注意不要向你的 sychronized block 傳入 nil!這將會從程式碼中移走執行緒安全。你可以通過在 objc_sync_nil 上加斷點來檢視是否發生了這樣的事情。
研究的下一步將是研究下 “synchronized block” 輸出的彙編,看看它是否跟我上面的例子相似。我打賭 @synchronized block 的彙編輸出不會跟任何我們設計的 Objective-C 程式碼相同,上面的程式碼充其量是 @synchronized 的工作模型。你能想到更好的模型麼?我的模型在哪些情形下會有瑕疵麼?告訴我吧!