本文整理自《Effective Objective-C 2.0》,通過分析比較不同的同步鎖的優缺點,使用GCD方法一步步找到更高效的同步鎖。
在Objective-C中,如果有多個線程要執行同一份代碼,那么這時就會出現線程安全問題。首先,我們看下什么時候線程安全問題。
線程安全
如果一段代碼所在的進程中有多個線程在同時運行,那么這些線程就有可能會同時運行這段代碼。假如多個線程每次運行結果和單線程運行的結果是一樣的,而且其他的變量的值也和預期的是一樣的,就是線程安全的。
由于可讀寫的全局變量及靜態變量(在 Objective-C 中還包括屬性和實例變量)可以在不同線程修改,所以這兩者也通常是引起線程安全問題的所在。
Objective-C中的同步鎖
在 Objective-C 中,如果有多個線程執行同一份代碼,那么有可能會出現線程安全問題。這種情況下,就需要使用所來實現某種同步機制。
在 GCD出現之前,有兩種方法,一種采用的是內置的“同步塊”(synchronization block),另一種方法是使用鎖對象。
同步塊(synchronization block)
- (void)synchronizedMethod {
@synchronized (self) {
//Safe
}
}
這種寫法會根據給定的對象,自動創建一個鎖,并等待塊中的代碼執行完畢。執行到這段代碼結尾處,鎖就釋放了。
該同步方法的優點就是我們不需要在代碼中顯式的創建鎖對象,便可以實現鎖的機制。然而,濫用@synchronized (self)則會降低代碼效率,因為公用同一個鎖的那些同步塊,都必須按順序執行。若是在self對象上頻繁加鎖,程序可能要等另一段與此無關的代碼執行完畢,才能繼續執行當前代碼,這樣效率就低了。
注:因為@synchronized (self)方法針對self只有一個鎖,相當于對于self的所有用到同步塊的地方都是公用同一個鎖,所以如果有多個同步塊,則其他的同步塊都要等待當前同步塊執行完畢才能繼續執行。
- (void)synchronizedAMethod {
@synchronized (self) {
//Safe
}
}
- (void)synchronizedBMethod {
@synchronized (self) {
//Safe
}
}
- (void)synchronizedCMethod {
@synchronized (self) {
//Safe
}
}
以上代碼,如果當前synchronizedAMethod
方法正在執行,則synchronizedBMethod
和synchronizedCMethod
方法需要等待synchronizedAMethod
完畢后才能執行,不能達到并發的效果。
鎖對象
@property (nonatomic,strong) NSLock *lock;
_lock = [[NSLock alloc] init];
- (void)synchronizedMethod {
[_lock lock];
//Safe
[_lock unlock];
}
以上是簡單鎖對象的實現方式,但是如果鎖使用不當,會出現死鎖現象,這時可以使用NSRecursiveLock這種“遞歸鎖”(recursive lock)。
除了以上鎖對象,還有NSConditionLock 條件鎖 、NSDistributedLock 分布式鎖 ,這些適用于不同的場景,這里就不展開說了。
以上這些鎖,使用的時候還是有缺陷的。在極端情況下,同步塊會導致死鎖,另外,效率也不見得高,而如果直接使用鎖對象的話,一旦遇到死鎖,就會非常麻煩。
GCD鎖
在開始說GCD鎖之前,我們先了解一下GCD的中的任務派發和隊列。
任務派發
任務派發方式 | 說明 |
---|---|
dispatch_sync() | 同步執行,完成了它預定的任務后才返回,阻塞當前線程 |
dispatch_async() | 異步執行,會立即返回,預定的任務會完成但不會等它完成,不阻塞當前線程 |
隊列種類
隊列種類 | 說明 |
---|---|
串行隊列 | 每次只能執行一個任務,并且必須等待前一個執行任務完成 |
并發隊列 | 一次可以并發執行多個任務,不必等待執行中的任務完成 |
GCD隊列種類
GCD隊列種類 | 獲取方法 | 隊列類型 | 說明 |
---|---|---|---|
主隊列 | dispatch_get_main_queue | 串行隊列 | 主線中執行 |
全局隊列 | dispatch_get_global_queue | 并發隊列 | 子線程中執行 |
用戶隊列 | dispatch_queue_create | 串并都可以 | 子線程中執行 |
以前同步鎖的實現方式
在Objective-C中,屬性就是開發者經常需要同步的地方。通常開發者想省事的話(以前我也是這樣覺得),會這樣寫:
- (NSString *)someString {
@synchronized (self) {
return _someString;
}
}
- (void)setSomeString:(NSString *)someString {
@synchronized (self) {
_someString = someString;
}
}
以上代碼除了上文提到的效率低以外,還有一個問題,就是該方法并不能保證訪問該對象時絕對是線程安全的。雖然,這種方法在訪問屬性時,確實是“原子”的,也必定能從中獲取到有效值,然而在同一線程上多次調用getter方法,每次獲取到的結果未必相同。在兩次訪問操作之間,其他線程可能會寫入新的屬性值。此時,只能保證讀寫操作是“原子”的,而多個線程的執行順序,我們沒有辦法控制。
使用GCD串行隊列來實現同步鎖
有種簡單而高效的方法可以替代同步塊或鎖對象,那就是使用“串行同步隊列”。將讀取操作以及寫入操作都安排在同一個隊列里,即可保證數據同步。
用法如下:
@property (nonatomic,strong) dispatch_queue_t syncQueue;
_syncQueue = dispatch_queue_create("com.effetiveobjectivec.syncQueue", NULL);
- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return _someString;
}
- (void)setSomeString:(NSString *)someString {
dispatch_sync(_syncQueue, ^{
_someString = someString;
});
}
此模式的思路是:把設置操作與獲取操作都安排在序列化的隊列里執行,這樣的話,所有針對屬性的訪問操作就都同步了。
注:getter方法中,用一個臨時變量來保存值,是因為在block中return的話,只是return到block中了,沒有真正返回到對應的getter方法中,而__block是為了可以在block中改變改臨時變量而用。
雖然問題解決了,但是我們還可以進一步優化。設置方法不一定非得是同步的。設置實例變量所用的塊,并不需要向設置方法返回什么值。那代碼可以改成:
- (void)setSomeString:(NSString *)someString {
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
這次把同步改成了異步,也許看來,這樣改動,性能是會有提升的,但是你測一下程序的性能,可能會發現這種寫法比原來慢。因為執行異步派發時,是需要拷貝塊。若拷貝塊所用的時間明顯超過執行塊所需的時間,則這種做法將比原來的更慢。
注:本例子代碼比較簡單,若是要執行的塊代碼邏輯比較復雜的話,那么該寫法可能還是比原來的塊些
使用GCD并發隊列來實現同步鎖
對于屬性的讀寫,我們希望多個獲取方法可以并發執行,而獲取方法與設置方法之間不能并發執行,利用這個特點,還能寫出更快一些的代碼來。此時正可體現出GCD的好處。而用同步鎖或鎖對象,是無法輕易實現下面這種方案的。這次我們使用并發隊列:
@property (nonatomic,strong) dispatch_queue_t syncQueue;
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString *)someString {
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
以上代碼,還無法正確實現同步。因為所有讀寫操作都會在同一個隊列上執行,而該隊列是并發隊列,所有讀取和寫入操作都可以隨時執行,沒有達到同步效果。此問題我們可以通過一個簡單的GCD功能解決--柵欄(barrier)。下列函數可以向隊列中派發塊,將其作為柵欄使用:
void dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block);
void dispatch_barrier_sync(dispatch_queue_t queue, dispatch_block_t block);
注:dispatch_barrier_async如果傳入自己創建的并行隊列時,會阻塞當前隊列執行,而不阻塞當前線程。
dispatch_barrier_sync如果傳入自己創建的并行隊列時,阻塞當前隊列的同時也會阻塞當前線程,請注意
并發隊列如果發現接下來要處理的塊是個柵欄塊,那么就一直要等當前所有并發塊都執行完畢,才會單獨執行這個柵欄塊。這待柵欄塊執行完畢,在按正常方式繼續向下處理。這樣就解決了并發隊列的同步問題。
GCD并發隊列中加入柵欄
本例中,可以用柵欄塊來實現屬性的設置方法。在設置方法中使用了柵欄塊之后,對屬性的讀取操作依然可以并發執行,但寫入操作卻必須單獨執行了。在下圖中演示的這個隊列中,有多個讀取操作,而且還有一個寫入操作。
實現代碼很簡單:
@property (nonatomic,strong) dispatch_queue_t syncQueue;
//_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
/* 這里應該使用自己創建的并發隊列,因為蘋果文檔中指出,如果使用的是全局隊列或者創建的不是并發隊列,
則dispatch_barrier_async實際上就相當于dispatch_async,就達不到我們想要的效果了 */
_syncQueue = dispatch_queue_create("com.effetiveobjectivec.syncQueue", DISPATCH_QUEUE_CONCURRENT);
- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString *)someString {
dispatch_barrier_async(_syncQueue, ^{
_someString = someString;
});
}
測試一下性能,你就會發現,這種做法肯定比使用串行隊列要快。其中,設置函數也可以改用同步柵欄塊來實現,那樣做可能會更高效,其原因之前已經解釋過了——這里就要權衡拷貝塊的時間和塊執行時間了