毫不誇張的說,80%的程式設計師對於多執行緒的理解都是淺陋和錯誤的。就拿我從事的iOS行業來說,雖然很多程式設計師可以對非同步、GCD等等與執行緒相關的概念說的天花亂墜。但是實質上深挖本質的話,大多數人並不能很好的區分Race Condition,Atomic,Immutable物件線上程安全中真正起到的作用。
所以今天就以這篇文章來談談我所理解的執行緒安全。
首先就允許我從Immutable來開始整篇話題吧。
Swift中的Immutable
用過Swift的人都知道,Swift相較於Objective-C有一個比較明顯的改動就是將結構體(Struct)和型別(Class)進行了分離。從某種方面來說,Swift將值型別和引用型別進行了明顯的區分。為什麼要這麼做?
- 避免了引用型別在被作為引數傳遞後被他人持有後修改,從而引發比較難以排查的問題。
- 在某些程度上提供了一定的執行緒安全(因為多執行緒本身的問題很大程式上出在寫修改的不確定性)。而Immutable 資料的好處在於一旦建立結束就無法修改,因此相當於任一一個執行緒在使用它的過程中僅僅是使用了讀的功能。
看到這,很多人開始歡呼了(嘲諷下WWDC那些“託”一般的粉絲,哈哈),覺得執行緒安全的問題迎刃而解了。
但事實上,我想說的是使用Immutable不直接等同於執行緒安全,不然在使用NSArray,NSDictionary等等Immutable物件之後,為啥還會有那麼多奇怪的bug出現?
指標與物件
有些朋友會問,Immutable都將一個物件變為不可變的“固態”了,為什麼還是不安全呢,在各個執行緒間傳遞的只是一份只讀檔案啊。
是的,對於一個Immutable的物件來說,它自身是不可變了。但是在我們的程式裡,我們總是需要有“東西”去指向我們的物件的吧,那這個“東西”是什麼?指向物件的指標。
指標想必大家都不會陌生。對於指標來說,其實它本質也是一種物件,我們更改指標的指向的時候,實質上就是對於指標的一種賦值。所以想象這樣一種場景,當你用一個指標指向一個Immutable物件的時候,在多執行緒更改的時候,你覺得你的指標修改是執行緒安全的嗎?這也就是為什麼有些人碰到一些跟NSArray這種Immutable物件的在多執行緒出現奇怪bug的時候會顯得一臉懵逼。
舉例:
1 2 3 4 5 6 7 8 |
// Thread A 其中immutableArrayA count 7 self.xxx = self.immutableArrayA; // Thread B 其中immutableArrayB count 4 self.xxx = self.immutableArrayB // main Thread [self.xxx objectAtIndex:5] |
上述這個程式碼片段,絕對是存線上程的安全的隱患的。
鎖
既然想到了多執行緒對於指標(或者物件)的修改,我們很理所當然的就會想到用鎖。在現如今iOS部落格氾濫的年代,大家都知道NSLock, OSSpinLock之類的可以用於短暫的Critical Section競態的鎖保護。
所以對於一些多執行緒中需要使用共享資料來源並支援修改操作的時候,比如NSMutableArray新增一些object的時候,我們可以寫出如下程式碼:
1 2 3 |
OSSpinLock(&_lock); [self.array addObject:@"hahah"]; OSSpinUnlock(&_lock); |
乍一看,這個沒問題了,這個就是最基本的防寫鎖。如果有多個程式碼同時嘗試新增進入self.array
,是會通過鎖搶佔的方式一個一個的方式的新增。
但是,這個東西有啥卵用嗎?原子鎖只能解決Race Condition的問題,但是它並不能解決任何你程式碼中需要有時序保證的邏輯。
比如如下這段程式碼:
1 2 3 |
if (self.xxx) { [self.dict setObject:@"ah" forKey:self.xxx]; } |
大家第一眼看到這樣的程式碼,是不是會認為是正確的?因為在設定key的時候已經提前進行了self.xxx
為非nil的判斷,只有非nil得情況下才會執行後續的指令。但是,如上程式碼只有在單執行緒的前提下才是正確的。
假設我們將上述程式碼目前執行的執行緒為Thread A
,當我們執行完if (self.xxx)
的語句之後,此時CPU將執行權切換給了Thread B
,而這個時候Thread B中呼叫了一句self.xxx = nil
。
那對於這種問題,我們有沒有比較好的解決方案呢?答案是存在的,就是使用區域性變數。
針對上述程式碼,我們進行如下修改:
1 2 3 4 |
__strong id val = self.xxx; if (val) { [self.dict setObject:@"ah" forKey:val]; } |
這樣,無論多少執行緒嘗試對self.xxx
進行修改,本質上的val
都會保持現有的狀態,符合非nil的判斷。
Objective-C的Property Setter多執行緒併發bug
最後我們回到經常使用的Objective-C來談談現實生活中經常出現的問題。相信各位對於Property的Setter概念都不陌生,self.xxx = @"kks"
其實就是呼叫了xxx
的setter方法。而Setter方法本質上就是如下這樣一段程式碼邏輯:
1 2 3 4 5 6 7 |
- (void)setXxx:(NSString *)newXXX { if (newXXX != _xxx) { [newXXX retain]; [_xxx release]; _userName = newXXX; } } |
比如Thread A 和 B同時對self.xxx
進行了賦值,當兩者都越過了if (newXXX != _xxx)
的判斷的時候,就會產生[_xxx release]
執行了兩次,造成過度釋放的crash危險。
有人說,呵呵,你這是MRC時代的寫法,我用了ARC,沒問題了吧。
ok,那讓我們來看看ARC時代是怎麼處理的,對於ARC中不復寫Setter的屬性(我相信是絕大多數情況),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 25 26 27 28 29 30 31 32 33 34 |
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) { id oldValue; // 計算結構體中的偏移量 id *slot = (id*) ((char*)self + offset); if (copy) { newValue = [newValue copyWithZone:NULL]; } else if (mutableCopy) { newValue = [newValue mutableCopyWithZone:NULL]; } else { // 某些程度的優化 if (*slot == newValue) return; newValue = objc_retain(newValue); } // 危險區 if (!atomic) { // 第一步 oldValue = *slot; // 第二步 *slot = newValue; } else { spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)]; _spin_lock(slotlock); oldValue = *slot; *slot = newValue; _spin_unlock(slotlock); } objc_release(oldValue); } |
由於我們一般宣告的物件都是nonatomic,所以邏輯會走到上述註釋危險區處。還是設想一下多執行緒對一個屬性同時設定的情況,我們首先線上程A處獲取到了執行第一步程式碼後的oldValue,然後此時執行緒切換到了B,B也獲得了第一步後的oldValue,所以此時就有兩處持有oldValue。然後無論是執行緒A或者執行緒B執行到最後都會執行objc_release(oldValue);。
如果不相信的話,可以嘗試如下這個小例子:
1 2 3 4 5 |
for (int i = 0; i 10000; i++) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ self.data = [[NSMutableData alloc] init]; }); } |
相信你很容易就能看到如下錯誤log:error for object: pointer being freed was not allocated。
結語
說了這麼多,本質上執行緒安全是個一直存在並且相對來說是個比較困難的問題,沒有絕對的銀彈。用了Immutable不代表可以完全拋棄鎖,用了鎖也不代表高枕無憂了。希望這篇文章能夠幫助大家更深入的思考下相關的問題,不要見到執行緒安全相關的問題就直接回答加鎖、使用Immutable資料之類的。
當然,其實Stick To GCD (dispatch_barrier)是最好的解決方案。
本文寫於頭昏腦漲之中,寫錯之處請大神多多指出。