在軟體程式設計中,多執行緒是個繞不開的話題。多執行緒的使用,能夠提高程式的執行效率,但也帶來新的問題:如何保證執行緒安全?
在維基百科中執行緒安全的解釋是:指某個函式、函式庫在多執行緒環境中被呼叫時,能夠正確地處理多個執行緒之間的共享變數,使程式功能正確完成。換句話說,就是某個變數在被某條執行緒訪問期間是“一致”的。這個“一致”指的是這條執行緒從開始訪問這個變數到結束訪問這個變數期間,這個變數不會發生任何變化。
那麼,保證某個變數的執行緒安全,也就可以理解成保證某個變數在某個特定時間段內是一致的。這個某個特定時間,也就可以理解成為執行緒安全的原子性粒度,具體下面有介紹。
例子
具體到iOS上,經常能看到下面的程式碼例子:
// 例子1
@property (atomic, assign) int num;
// thread A
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread A: %d\d ",self.num);
}
// thread B
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread B: %d\d ",self.num);
}複製程式碼
// 例子2
@property (atomic, strong) NSString * stringA;
//thread A
for (int i = 0; i < 10000; 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 < 10000; i ++) {
if (self.stringA.length >= 10) {
NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
}
NSLog(@"Thread B: %@\n", self.stringA);
}複製程式碼
例子A最後輸出不一定是20000,例子B有可能會crash。這兩個例子說明了一個問題:property加上atomic關鍵字,並不一定能保證屬性的執行緒安全
。
執行緒安全的原子性粒度
那為什麼用了atomic
關鍵字不能保證上述場景的property變數的執行緒安全?
atomic
關鍵字的作用其實就是對屬性的讀寫操作進行加鎖,換句話說就是對屬性的Setter/Getter操作加鎖。但atomic
關鍵字只能保證在同一時間段內,最多有且只有一條執行緒對當前關鍵字進行讀寫。
例子1中self.num = self.num + 1;
包含了三個操作:通過Getter讀取num,對讀取的num進行加1,將加1後的結果寫回num。atomic
關鍵字能保證每一個操作都是原子的。但是,每個操作之間的間隙時間,atomic
不能保證屬性不被其他執行緒訪問。在TheadA對num進行加1操作後,此時CPU時間被分配給了Thread B,Thread B有可能對num進行了修改,當CPU時間再次分配回Thread A的時候,此時的num+1不一定是原來的num+1,此時Thread 將當前的num值修改成原來的的num+1的值,最後導致預期值跟實際值不一樣,這種場景就是多執行緒的執行緒不安全
。而且使用atomic
無法避免一個問題,如果多執行緒對屬性的訪問是直接通過Ivar來訪問, 不通過呼叫Getter/Setter來訪問的話,atomic
沒有任何作用。
同樣,例子2也是一樣,當執行程式碼self.stringA.length >= 10
時,假設stringA的值是“a very long string”,符合判斷條件,此時執行緒切換到Thread A,Thread A將stringA修改成“string”。這時CPU時間再次分配給Thread B,此時Thread B會執行[self.stringA substringWithRange:NSMakeRange(0, 10)]
,但當前的stringA的值已經被Thread A修改成了“string”,所以會字串訪問越界,直接crash。
例子1和例子2出現問題的原因在於雖然對字串的每次讀寫都是安全的,但是並不能保證各個執行緒組合起來的操作是安全的,這就是一個執行緒安全的原子性粒度問題。atomic
的原子粒度是Getter/Setter,但對多行程式碼的操作不能保證原子性。針對例子1和例子2的問題,更好的辦法是使用鎖機制。
// 例子3
// thread A
[_lock lock];
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread A: %d\d ",self.num);
}
[_lock unlock];
// thread B
[_lock lock];
for (int i = 0; i < 10000; i++) {
self.num = self.num + 1;
NSLog(@"Thread B: %d\d ",self.num);
}
[_lock unlock];複製程式碼
// 例子4
//thread A
[_lock lock];
for (int i = 0; i < 10000; 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];
for (int i = 0; i < 10000; i ++) {
if (self.stringA.length >= 10) {
NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
}
NSLog(@"Thread B: %@\n", self.stringA);
}
[_lock unlock];複製程式碼
對程式碼進行加鎖後,只有對加鎖程式碼加鎖了的執行緒才能訪問加鎖程式碼,這樣就保證了加鎖程式碼不會被其他執行緒執行,從而從更大粒度上保證了執行緒安全。如果使用了鎖機制進行程式碼級原子粒度的控制,就沒有必要再使用更小粒度的atomic
了。因為大粒度的原子性已經能夠保障相關業務程式碼的執行緒安全,如果再加多更小粒度的原子性控制,一來會多此一舉,二來atomic
是一種更小粒度的加鎖機制,會對效能有不少的影響,所以一般來說如果使用了更大粒度的原子性,就沒有必要使用更小粒度的原子性了,所以加鎖後的程式碼中的屬性變數,沒有必要再使用atomic
。
不加鎖的小技巧
對於例子2,如果不加鎖,怎麼保證不會程式碼不會crash?
// 例子5
for (int i = 0; i < 10000; i ++) {
NSString *immutableTempString = self.stringA;
if (immutableTempString.length >= 10) {
NSString* subStr = [immutableTempString substringWithRange:NSMakeRange(0, 10)];
}
}複製程式碼
例子2發生crash的原因是,stringA指向的記憶體區域發生了變化,訪問時發生了越界。但例子5中則不會有這種情況,因為例子5中使用了臨時變數immutableTempString,指向stringA未發生變化前的記憶體空間,當stringA指向的記憶體發生變化後,由於原來stringA指向的記憶體被immutableTempString指向,所以暫時不會被系統回收。當[immutableTempString substringWithRange:NSMakeRange(0, 10)]
呼叫時,immutableTempString指向的還是原來的stringA的值,所以不會發生crash。這種方法的原理是,通過使用臨時變數來持有原來變動前的值,所有操作都對這個臨時變數指向的值進行操作,而不是直接使用屬性指向的值,這樣的話能保證上下文情景下變數的值是一致的,而且由於變數是臨時變數,所以只會對當前執行緒可見,對其他執行緒不可見,從而在某種程度上保證了執行緒安全。
總結
在iOS中,不能簡單的認為只要加上atomic
關鍵字就能保證屬性的執行緒安全。而在實際使用中,由於業務程式碼的複雜性,大部分情況下都會使用比atomic
更大粒度的鎖控制。由於使用了更大粒度的鎖,從效能和必要性方面考慮,就不需要再使用atomic
了。在某些情況下,如果不能採用加鎖的做法,又要保證程式碼不會發生crash,可以使用臨時變數指向原值,保證一定程度的執行緒安全。
總而言之,多執行緒的執行緒安全是個複雜的問題,最好的做法是儘量避免多執行緒的設計