探究iOS多線程究竟不安全在哪里?
前言
共享狀態(tài),多線程共同訪問某個(gè)對(duì)象的property,在iOS編程里是很普遍的使用場景,我們就從Property的多線程安全說起。
Property
當(dāng)我們討論property多線程安全的時(shí)候,很多人都知道給property加上atomic attribute之后,可以一定程度的保障多線程安全,類似:
@property (atomic, strong) NSString* userName;
事情并沒有看上去這么簡單,要分析property在多線程場景下的表現(xiàn),需要先對(duì)property的類型做區(qū)分。
我們可以簡單的將property分為值類型和對(duì)象類型,值類型是指primitive type,包括int, long, bool等非對(duì)象類型,另一種是對(duì)象類型,聲明為指針,可以指向某個(gè)符合類型定義的內(nèi)存區(qū)域。
上述代碼中userName明顯是個(gè)對(duì)象類型,當(dāng)我們訪問userName的時(shí)候,訪問的有可能是userName本身,也有可能是userName所指向的內(nèi)存區(qū)域。
比如:
self.userName = @"peak";
是在對(duì)指針本身進(jìn)行賦值。而
[self.userName rangeOfString:@"peak"];
是在訪問指針指向的字符串所在的內(nèi)存區(qū)域,這二者并不一樣。
所以我們可以大致上將property分為三類:
分完類之后,我們需要明白這三類property的內(nèi)存模型。
Memory Layout
當(dāng)我們討論多線程安全的時(shí)候,其實(shí)是在討論多個(gè)線程同時(shí)訪問一個(gè)內(nèi)存區(qū)域的安全問題。針對(duì)同一塊區(qū)域,我們有兩種操作,讀(load)和寫(store),讀和寫同時(shí)發(fā)生在同一塊區(qū)域的時(shí)候,就有可能出現(xiàn)多線程不安全。所以展開討論之前,先要明白上述三種property的內(nèi)存模型,可用如下圖示:
以64位系統(tǒng)為例,指針NSString*是8個(gè)字節(jié)的內(nèi)存區(qū)域,int count是個(gè)4字節(jié)的區(qū)域,而@“Peak”是一塊根據(jù)字符串長度而定的內(nèi)存區(qū)域。
當(dāng)我們訪問property的時(shí)候,實(shí)際上是訪問上圖中三塊內(nèi)存區(qū)域。
self.userName = @"peak";
是修改第一塊區(qū)域。
self.count = 10;
是在修改第二塊區(qū)域。
[self.userName rangeOfString:@"peak"];
是在讀取第三塊區(qū)域。
不安全的定義
明白了property的類型以及他們對(duì)應(yīng)的內(nèi)存模型,我們再來看看不安全的定義。Wikipedia如是說:
A piece of code is thread-safe if it manipulates shared data structures only in a manner that guarantees safe execution by multiple threads at the same time
這段定義看起來還是有點(diǎn)抽象,我們可以將多線程不安全解釋為:多線程訪問時(shí)出現(xiàn)意料之外的結(jié)果。這個(gè)意料之外的結(jié)果包含幾種場景,不一定是指crash,后面再一一分析。
先來看下多線程是如何同時(shí)訪問內(nèi)存的。不考慮CPU cache對(duì)變量的緩存,內(nèi)存訪問可以用下圖表示:
從上圖中可以看出,我們只有一個(gè)地址總線,一個(gè)內(nèi)存。即使是在多線程的環(huán)境下,也不可能存在兩個(gè)線程同時(shí)訪問同一塊內(nèi)存區(qū)域的場景,內(nèi)存的訪問一定是通過一個(gè)地址總線串行排隊(duì)訪問的,所以在繼續(xù)后續(xù)之前,我們先要明確幾個(gè)結(jié)論:
結(jié)論一:內(nèi)存的訪問時(shí)串行的,并不會(huì)導(dǎo)致內(nèi)存數(shù)據(jù)的錯(cuò)亂或者應(yīng)用的crash。
結(jié)論二:如果讀寫(load or store)的內(nèi)存長度小于等于地址總線的長度,那么讀寫的操作是原子的,一次完成。比如bool,int,long在64位系統(tǒng)下的單次讀寫都是原子操作。
接下來我們根據(jù)上面三種property的分類逐一看下多線程的不安全場景。
值類型Property
先以BOOL值類型為例,當(dāng)我們有兩個(gè)線程訪問如下property的時(shí)候:
@property (nonatomic, assgin) BOOL isDeleted; //thread 1 bool isDeleted = self.isDeleted; //thread 2 self.isDeleted = false;
線程1和線程2,一個(gè)讀(load),一個(gè)寫(store),對(duì)于BOOL isDeleted的訪問可能有先后之分,但一定是串行排隊(duì)的。而且由于BOOL大小只有1個(gè)字節(jié),64位系統(tǒng)的地址總線對(duì)于讀寫指令可以支持8個(gè)字節(jié)的長度,所以對(duì)于BOOL的讀和寫操作我們可以認(rèn)為是原子的,所以當(dāng)我們聲明BOOL類型的property的時(shí)候,從原子性的角度看,使用atomic和nonatomic并沒有實(shí)際上的區(qū)別(當(dāng)然如果重載了getter方法就另當(dāng)別論了)。
如果是int類型呢?
@property (nonatomic, assgin) int count; //thread 1 int curCount = self.count; //thread 2 self.count = 1;
同理int類型長度為4字節(jié),讀和寫都可以通過一個(gè)指令完成,所以理論上讀和寫操作都是原子的。從訪問內(nèi)存的角度看nonatomic和atomic也并沒有什么區(qū)別。
atomic到底有什么用呢?據(jù)我所知,用處有二:
用處一: 生成原子操作的getter和setter。
設(shè)置atomic之后,默認(rèn)生成的getter和setter方法執(zhí)行是原子的。也就是說,當(dāng)我們在線程1執(zhí)行g(shù)etter方法的時(shí)候(創(chuàng)建調(diào)用棧,返回地址,出棧),線程B如果想執(zhí)行setter方法,必須先等getter方法完成才能執(zhí)行。舉個(gè)例子,在32位系統(tǒng)里,如果通過getter返回64位的double,地址總線寬度為32位,從內(nèi)存當(dāng)中讀取double的時(shí)候無法通過原子操作完成,如果不通過atomic加鎖,有可能會(huì)在讀取的中途在其他線程發(fā)生setter操作,從而出現(xiàn)異常值。如果出現(xiàn)這種異常值,就發(fā)生了多線程不安全。
用處二:設(shè)置Memory Barrier
對(duì)于Objective C的實(shí)現(xiàn)來說,幾乎所有的加鎖操作最后都會(huì)設(shè)置memory barrier,atomic本質(zhì)上是對(duì)getter,setter加了鎖,所以也會(huì)設(shè)置memory barrier。官方文檔表述如下:
Note: Most types of locks also incorporate a memory barrier to ensure that any preceding load and store instructions are completed before entering the critical section.
memory barrier有什么用處呢?
memory barrier能夠保證內(nèi)存操作的順序,按照我們代碼的書寫順序來。聽起來有點(diǎn)不可思議,事實(shí)是編譯器會(huì)對(duì)我們的代碼做優(yōu)化,在它認(rèn)為合理的場景改變我們代碼最終翻譯成的機(jī)器指令順序。也就是說如下代碼:
self.intA = 0; //line 1 self.intB = 1; //line 2
編譯器可能在一些場景下先執(zhí)行l(wèi)ine2,再執(zhí)行l(wèi)ine1,因?yàn)樗J(rèn)為A和B之間并不存在依賴關(guān)系,雖然在代碼執(zhí)行的時(shí)候,在另一個(gè)線程intA和intB存在某種依賴,必須要求line1先于line2執(zhí)行。
如果設(shè)置property為atomic,也就是設(shè)置了memory barrier之后,就能夠保證line1的執(zhí)行一定是先于line2的,當(dāng)然這種場景非常罕見,一則是出現(xiàn)變量跨線程訪問依賴,二是遇上編譯器的優(yōu)化,兩個(gè)條件缺一不可。這種極端的場景下,atomic確實(shí)可以讓我們的代碼更加多線程安全一點(diǎn),但我寫iOS代碼至今,還未遇到過這種場景,較大的可能性是編譯器已經(jīng)足夠聰明,在我們需要的地方設(shè)置memory barrier了。
是不是使用了atomic就一定多線程安全呢?我們可以看看如下代碼:
@property (atomic, assign) int intA; //thread A for (int i = 0; i < 10000; i ++) { self.intA = self.intA + 1; NSLog(@"Thread A: %d\n", self.intA); } //thread B for (int i = 0; i < 10000; i ++) { self.intA = self.intA + 1; NSLog(@"Thread B: %d\n", self.intA); }
即使我將intA聲明為atomic,最后的結(jié)果也不一定會(huì)是20000。原因就是因?yàn)閟elf.intA = self.intA + 1;不是原子操作,雖然intA的getter和setter是原子操作,但當(dāng)我們使用intA的時(shí)候,整個(gè)語句并不是原子的,這行賦值的代碼至少包含讀取(load),+1(add),賦值(store)三步操作,當(dāng)前線程store的時(shí)候可能其他線程已經(jīng)執(zhí)行了若干次store了,導(dǎo)致最后的值小于預(yù)期值。這種場景我們也可以稱之為多線程不安全。
指針Property
指針Property一般指向一個(gè)對(duì)象,比如:
@property (atomic, strong) NSString* userName;
無論iOS系統(tǒng)是32位系統(tǒng)還是64位,一個(gè)指針的值都能通過一個(gè)指令完成load或者store。但和primitive type不同的是,對(duì)象類型還有內(nèi)存管理的相關(guān)操作。在MRC時(shí)代,系統(tǒng)默認(rèn)生成的setter類似如下:
- (void)setUserName:(NSString *)userName { if(_uesrName != userName) { [userName retain]; [_userName release]; _userName = userName; } }
不僅僅是賦值操作,還會(huì)有retain,release調(diào)用。如果property為nonatomic,上述的setter方法就不是原子操作,我們可以假設(shè)一種場景,線程1先通過getter獲取當(dāng)前_userName,之后線程2通過setter調(diào)用[_userName release];,線程1所持有的_userName就變成無效的地址空間了,如果再給這個(gè)地址空間發(fā)消息就會(huì)導(dǎo)致crash,出現(xiàn)多線程不安全的場景。
到了ARC時(shí)代,Xcode已經(jīng)替我們處理了retain和release,絕大部分時(shí)候我們都不需要去關(guān)心內(nèi)存的管理,但retain,release其實(shí)還是存在于最后運(yùn)行的代碼當(dāng)中,atomic和nonatomic對(duì)于對(duì)象類的property聲明理論上還是存在差異,不過我在實(shí)際使用當(dāng)中,將NSString*設(shè)置為nonatomic也從未遇到過上述多線程不安全的場景,極有可能ARC在內(nèi)存管理上的優(yōu)化已經(jīng)將上述場景處理過了,所以我個(gè)人覺得,如果只是對(duì)對(duì)象類property做read,write,atomic和nonatomic在多線程安全上并沒有實(shí)際差別。
指針Property指向的內(nèi)存區(qū)域
這一類多線程的訪問場景是我們很容易出錯(cuò)的地方,即使我們聲明property為atomic,依然會(huì)出錯(cuò)。因?yàn)槲覀冊L問的不是property的指針區(qū)域,而是property所指向的內(nèi)存區(qū)域??梢钥慈缦麓a:
@property (atomic, strong) NSString* stringA; //thread A for (int i = 0; i < 100000; i ++) { if (i % 2 == 0) { self.stringA = @"a very long string"; } else { self.stringA = @"string"; } NSLog(@"Thread A: %@\n", self.stringA); } //thread B for (int i = 0; i < 100000; i ++) { if (self.stringA.length >= 10) { NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)]; } NSLog(@"Thread B: %@\n", self.stringA); }
雖然stringA是atomic的property,而且在取substring的時(shí)候做了length判斷,線程B還是很容易crash,因?yàn)樵谇耙豢套xlength的時(shí)候self.stringA = @"a very long string";,下一刻取substring的時(shí)候線程A已經(jīng)將self.stringA = @"string";,立即出現(xiàn)out of bounds的Exception,crash,多線程不安全。
同樣的場景還存在對(duì)集合類操作的時(shí)候,比如:
@property (atomic, strong) NSArray* arr; //thread A for (int i = 0; i < 100000; i ++) { if (i % 2 == 0) { self.arr = @[@"1", @"2", @"3"]; } else { self.arr = @[@"1"]; } NSLog(@"Thread A: %@\n", self.arr); } //thread B for (int i = 0; i < 100000; i ++) { if (self.arr.count >= 2) { NSString* str = [self.arr objectAtIndex:1]; } NSLog(@"Thread B: %@\n", self.arr); }
同理,即使我們在訪問objectAtIndex之前做了count的判斷,線程B依舊很容易crash,原因也是由于前后兩行代碼之間arr所指向的內(nèi)存區(qū)域被其他線程修改了。
所以你看,真正需要操心的是這一類內(nèi)存區(qū)域的訪問,即使聲明為atomic也沒有用,我們平常App出現(xiàn)莫名其妙難以重現(xiàn)的多線程crash多是屬于這一類,一旦在多線程的場景下訪問這類內(nèi)存區(qū)域的時(shí)候,要提起十二分的小心。如何避免這類crash后面會(huì)談到。
Property多線程安全小結(jié):
簡而言之,atomic的作用只是給getter和setter加了個(gè)鎖,atomic只能保證代碼進(jìn)入getter或者setter函數(shù)內(nèi)部時(shí)是安全的,一旦出了getter和setter,多線程安全只能靠程序員自己保障了。所以atomic屬性和使用property的多線程安全并沒什么直接的聯(lián)系。另外,atomic由于加鎖也會(huì)帶來一些性能損耗,所以我們在編寫iOS代碼的時(shí)候,一般聲明property為nonatomic,在需要做多線程安全的場景,自己去額外加鎖做同步。
如何做到多線程安全?
討論到這里,其實(shí)怎么做到多線程安全也比較明朗了,關(guān)鍵字是atomicity(原子性),只要做到原子性,小到一個(gè)primitive type變量的訪問,大到一長段代碼邏輯的執(zhí)行,原子性能保證代碼串行的執(zhí)行,能保證代碼執(zhí)行到一半的時(shí)候,不會(huì)有另一個(gè)線程介入。
原子性是個(gè)相對(duì)的概念,它所針對(duì)的對(duì)象,粒度可大可小。
比如下段代碼:
if (self.stringA.length >= 10) { NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)]; }
是非原子性的。
但加鎖以后:
//thread A [_lock lock]; for (int i = 0; i < 100000; i ++) { if (i % 2 == 0) { self.stringA = @"a very long string"; } else { self.stringA = @"string"; } NSLog(@"Thread A: %@\n", self.stringA); } [_lock unlock]; //thread B [_lock lock]; if (self.stringA.length >= 10) { NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)]; } [_lock unlock];
整段代碼就具有原子性了,就可以認(rèn)為是多線程安全了。
再比如:
if (self.arr.count >= 2) { NSString* str = [self.arr objectAtIndex:1]; }
是非原子性的。
而
//thread A [_lock lock]; for (int i = 0; i < 100000; i ++) { if (i % 2 == 0) { self.arr = @[@"1", @"2", @"3"]; } else { self.arr = @[@"1"]; } NSLog(@"Thread A: %@\n", self.arr); } [_lock unlock]; //thread B [_lock lock]; if (self.arr.count >= 2) { NSString* str = [self.arr objectAtIndex:1]; } [_lock unlock];
是具有原子性的。注意,讀和寫都需要加鎖。
這也是為什么我們在做多線程安全的時(shí)候,并不是通過給property加atomic關(guān)鍵字來保障安全,而是將property聲明為nonatomic(nonatomic沒有g(shù)etter,setter的鎖開銷),然后自己加鎖。
如何使用哪種鎖?
iOS給代碼加鎖的方式有很多種,常用的有:
- @synchronized(token)
- NSLock
- dispatch_semaphore_t
- OSSpinLock
這幾種鎖都可以帶來原子性,性能的損耗從上至下依次更小。
我個(gè)人建議是,在編寫應(yīng)用層代碼的時(shí)候,除了OSSpinLock之外,哪個(gè)順手用哪個(gè)。相較于這幾個(gè)鎖的性能差異,代碼邏輯的正確性更為重要。而且這幾者之間的性能差異對(duì)用戶來說,絕大部分時(shí)候都感知不到。
當(dāng)然我們也會(huì)遇到少數(shù)場景需要追求代碼的性能,比如編寫framework,或者在多線程讀寫共享數(shù)據(jù)頻繁的場景,我們需要大致了解鎖帶來的損耗到底有多少。
官方文檔有個(gè)數(shù)據(jù),使用Intel-based iMac with a 2 GHz Core Duo processor and 1 GB of RAM running OS X v10.5測試,獲取mutex有大概0.2ms的損耗,我們可以認(rèn)為鎖帶來的損耗大致在ms級(jí)別。
Atomic Operations
其實(shí)除了各種鎖之外,iOS上還有另一種辦法來獲取原子性,使用Atomic Operations,相比鎖的損耗要小一個(gè)數(shù)量級(jí)左右,在一些追求高性能的第三方Framework代碼里可以看到這些Atomic Operations的使用。這些atomic operation可以在/usr/include/libkern/OSAtomic.h中查到:
比如
_intA ++;
是非原子性的。
而
OSAtomicIncrement32(&(_intA));
是原子性的,多線程安全的。
Atomic Operation只能應(yīng)用于32位或者64位的數(shù)據(jù)類型,在多線程使用NSString或者NSArray這類對(duì)象的場景,還是得使用鎖。
大部分的Atomic Operation都有OSAtomicXXX,OSAtomicXXXBarrier兩個(gè)版本,Barrier就是前面提到的memory barrier,在多線程多個(gè)變量之間存在依賴的時(shí)候使用Barrier的版本,能夠保證正確的依賴順序。
對(duì)于平時(shí)編寫應(yīng)用層多線程安全代碼,我還是建議大家多使用@synchronized,NSLock,或者dispatch_semaphore_t,多線程安全比多線程性能更重要,應(yīng)該在前者得到充分保證,猶有余力的時(shí)候再去追求后者。
盡量避免多線程的設(shè)計(jì)
無論我們寫過多少代碼,都必須要承認(rèn)多線程安全是個(gè)復(fù)雜的問題,作為程序員我們應(yīng)該盡可能的避免多線程的設(shè)計(jì),而不是去追求高明的使用鎖的技能。
后面我會(huì)寫一篇文章,介紹函數(shù)式編程及其核心思想,即使我們使用非函數(shù)式的編程語言,比如Objective C,也能極大的幫助我們避免多線程安全的問題。
總結(jié)
iOS下多線程不安全的分析至此結(jié)束了,如何編寫多線程安全的代碼,說到底還是在于對(duì)memory layout和原子性的理解,也希望這篇文章將atomic和nonatomic的真正區(qū)別解釋清楚了:)。如果有疑問大家可以留言交流。
相關(guān)文章
解析Objective-C?中?`+load`?方法的執(zhí)行順序
在?Objective-C?中,+load?方法是在類或分類被加載到內(nèi)存時(shí)調(diào)用的,它在程序啟動(dòng)過程中非常早的階段執(zhí)行,用于在類或分類被加載時(shí)進(jìn)行一些初始化工作,這篇文章主要介紹了?Objective-C?中?`+load`?方法的執(zhí)行順序,需要的朋友可以參考下2024-07-07iOS 生成plist文件,在項(xiàng)目中代碼創(chuàng)建plist的實(shí)例
下面小編就為大家分享一篇iOS 生成plist文件,在項(xiàng)目中代碼創(chuàng)建plist的實(shí)例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2018-02-02關(guān)于iOS自帶九宮格拼音鍵盤和Emoji表情之間的一些坑
這篇文章主要給大家介紹了關(guān)于iOS自帶九宮格拼音鍵盤和Emoji表情之間的一些坑文中通過示例代碼介紹的非常詳細(xì),對(duì)各位iOS開發(fā)者們具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-05-05iOS實(shí)現(xiàn)攝像頭實(shí)時(shí)采集圖像
這篇文章主要為大家詳細(xì)介紹了iOS實(shí)現(xiàn)攝像頭實(shí)時(shí)采集圖像,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-04-04iOS WKWebview 白屏檢測實(shí)現(xiàn)的示例
這篇文章主要介紹了iOS WKWebview 白屏檢測實(shí)現(xiàn)的示例,幫助大家更好的進(jìn)行ios開發(fā),感興趣的朋友可以了解下2020-10-10