[編寫高質量iOS程式碼的52個有效方法](十)Grand Central Dispatch(GCD)

究極死胖獸發表於2016-07-28

[編寫高質量iOS程式碼的52個有效方法](十)Grand Central Dispatch(GCD)

參考書籍:《Effective Objective-C 2.0》 【英】 Matt Galloway

先睹為快

41.多用派發佇列,少用同步鎖

42.多用GCD,少用performSelector系列方法

43.掌握GCD及操作佇列的使用時機

44.通過Dispatch Group機制,根據系統資源狀況來執行任務

45.使用dispatch_once來執行只需要執行一次的執行緒安全程式碼

46.不要使用dispatch_get_current_queue

目錄

第41條:多用派發佇列,少用同步鎖

在Objective-C中,如果有多個執行緒要執行同一份程式碼,那麼有時可能會出問題。這種情況下通常要使用鎖來實現某種同步機制。在GCD出現之前,有兩種方法:

// 使用內建同步塊@synchronized
- (void)synchronizedMethod{
    @synchronized(self){
        // Safe
    }
}

// 使用NSLock物件
_lock = [[NSLock alloc] init];

- (void)synchronizedMethod{
    [_lock lock];
    // Safe
    [_lock unlock];
}

這兩種方法都很好,但都有缺陷。同步塊會降低程式碼效率,如在本例中,在self物件上加鎖,程式可能要等另一段與此無關的程式碼執行完畢,才能繼續執行當前程式碼。而直接用鎖物件的話,一旦遇到死鎖就會非常麻煩。

替代方案就是使用GCD,下面以開發者自己實現的原子屬性的存取方法為例:

// 用同步塊實現
- (NSString*)someString{
    @synchronized(self){
        return _someString;
    }
}

- (void)setSomeString:(NSString*)someString{
    @synchronized(self){
        _someString = someString;
    }
}

// 用GCD實現
// 建立一個序列佇列
_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NUll);

- (NSString*)someString{
    __block NSString *localSomeString;
    dispatch_sync(_syncQueue, ^{
        localSomeString = _someString;
    });
    return localSomeString;
}

- (void)setSomeString:(NSString*)someString{
    dispatch_sync(_syncQueue, ^{
        _someString = someString;
    });
}

假如有很多屬性都使用了@synchronized(self),那麼每個屬性的同步塊都要等其他所有同步塊執行完畢後才能執行,且這樣做未必能保證執行緒安全,如在同一個執行緒上多次呼叫getter方法,每次獲取到的結果未必相同,在兩次訪問操作之間,其他執行緒可能會寫入新的屬性值。

本例GCD程式碼採用的是序列同步佇列,將讀取操作及寫入操作都安排在同一個佇列中,可保證資料同步。

可以根據實際需求進一步優化程式碼,例如,讓屬性的讀取操作都可以併發執行,但是寫入操作必須單獨執行的情景:

// 建立一個併發佇列
_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{
    // 將寫入操作放入非同步柵欄塊中執行
    // 注:barrier表示柵欄,併發佇列如果發現接下來需要處理的塊為柵欄塊,那麼就會等待當前併發塊都執行完畢後再單獨執行柵欄塊,執行完柵欄塊後再以正常方式繼續向下處理。
    dispatch_barrier_async(_syncQueue, ^{
        _someString = someString;
    });
}

第42條:多用GCD,少用performSelector系列方法

Objective-C本質上是一門非常動態的語言,NSObject定義了幾個方法,令開發者可以隨意呼叫任何方法,這些方法可以推遲執行方法呼叫,也可以指定執行方法所有的執行緒。其中最簡單的就是performSelector:

- (id)performSelector:(SEL)selector

// performSelector方法與直接呼叫選擇子等效,所以以下兩行程式碼執行效果相同
[object performSelector:@selector(selectorName)];
[object selectorName];

如果選擇器是在執行期決定的,那麼就能體現出performSelector的強大,等於在動態繫結之上再次實現了動態繫結。

SEL selector;
if(/* some contidion */){
    selector = @selector(foo);
}else{
    selector = @selector(bar);
}
[object performSelector:selector];

但是這樣做的話,在ARC下編譯程式碼,編譯器會發出警告,提示performSelector可能會導致記憶體洩漏。原因在於編譯器並不知道將要呼叫的選擇器是什麼。所以沒辦法運用ARC的記憶體管理規則來判定返回的值是不是應該釋放。鑑於此,ARC採取比較謹慎的操作,即不新增釋放操作,在方法返回物件時已將其保留的情況下會發生記憶體洩漏。

performSelector系列方法的另一個侷限性在於最多隻能接受兩個引數,且接受引數型別必須為物件。下面是performSelector系列的常用方法:

// 延遲執行
- (id)performSelector:(SEL)selector withObject:(id)argument afterDelay:(NSTimeInterval)delay
// 由某執行緒執行
- (id)performSelector:(SEL)selector onThread:(NSThread*)thread withObject:(id)argument waitUntilDone:(BOOL)wait
// 由主執行緒執行
- (id)performSelectorOnMainThread:(SEL)selector withObject:(id)argument waitUntilDone:(BOOL)wait

這些方法都可以用GCD來替代其功能:

// 延遲執行方法
// 使用performSelector
[self performSelector:@selector(doSomething) withObject:nil afterDelay:5.0];

// 使用GCD
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^(void){
    [self doSomething];
});

// 在主執行緒中執行方法
// 使用performSelector
[self performSelectorOnMainThread:@selector(doSomething) withObject:nil waitUntilDone:NO];

// 使用GCD 如果waitUntilDone為YES,則用dispatch_sync
dispatch_async(dispatch_get_main_queue(),^{
    [self doSomething];
});

第43條:掌握GCD及操作佇列的使用時機

GCD技術確實很棒,不過有時候採用標準系統庫的元件,效果會更好。GCD技術的同步機制非常優秀,且對於那些只需執行一次的程式碼來說,使用GCD最方便。但在執行後臺任務時,還可以使用操作佇列(NSOperationQueue)。

兩者的差別很多,最大的區別在於,GCD是純C的API,而操作佇列是Objective-C的物件。GCD中,任務用塊來表示,是一個輕量級的資料結構,而操作(NSOperation)則是個更為重量級的Objective-C物件。需要更具物件帶來的開銷和使用完整物件的好處來權衡使用哪種技術。

操作佇列的優勢:
1. 直接在NSOperation物件上呼叫cancel方法即可取消操作,而GCD一旦分派任務就無法取消。
2. 可以指定操作間的依賴關係,使特定操作必須在另一個操作執行完畢後方可執行。
3. 可以通過KVO(鍵值觀察)來監控NSOperation物件的屬性變化(isCancelled,isFinished等)
4. 可以指定操作的優先順序
5. 可以通過重用NSOperation物件來實現更豐富的功能

想要確定哪種方案最佳,最好還是測試一下效能。

第44條:通過Dispatch Group機制,根據系統資源狀況來執行任務

dispatch group是GCD的一項特性,能夠把任務分組。呼叫者可以等待這組任務執行完畢,也可以在提供回撥函式之後繼續往下執行,這組任務完成時,呼叫者會得到通知。

如果想令某容器中的每個物件都執行某項任務,並且等待所有任務執行完畢,那麼就可以使用這個GCD特性來實現:

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// 建立dispatch group
dispatch_group_t group = dispatch_group_create();
for (id object in colletion){
    // 派發任務
    dispatch_group_async(group, queue, ^{
        [object performTask];    
    });
}
// 等待組內任務執行完畢
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

假如當前執行緒不應阻塞,而開發者又想在這組任務全部完成後收到訊息,可用notify函式來替代wait

// notify回撥時所選用的佇列可以根據情況來定,這裡用的是主佇列
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    // 完成任務後繼續接下來的操作
});

也可以把某些任務放在優先順序高的執行緒上執行,同時所有任務仍然屬於一個group

// 建立兩個優先順序不同的佇列
dispatch_queue_t lowPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
dispatch_queue_t HighPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
// 建立dispatch group
dispatch_group_t group = dispatch_group_create();

for (id object in lowPriorityColletion){
    dispatch_group_async(group, lowPriorityQueue, ^{
        [object performTask];    
    });
}

for (id object in HighPriorityColletion){
    dispatch_group_async(group, HighPriorityQueue, ^{
        [object performTask];    
    });
}

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    // 完成任務後繼續接下來的操作
});

而dispatch group也不是必須使用,編譯某個容器,並在其每個元素上執行任務,可以用apply函式,下面以陣列為例:

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(array.count, queue, ^(size_t i) {
    id object = array[i];
    [object performTask];
});

但是使用apply函式會持續阻塞,直到所有任務都執行完畢為止。由此可見,假如把塊派給了當前佇列(或者體系中高於當前佇列的序列佇列),就將導致死鎖。若想在後臺執行任務,則應使用dispatch group。

第45條:使用dispatch_once來執行只需要執行一次的執行緒安全程式碼

單例模式的常見實現方式為,在類中編寫名為sharedInstance的方法,該方法只會返回全類共用的單例例項,而不會每次呼叫時都建立新的例項。下面是用同步塊來實現單例模式:

@implementation EOCClass

+ (id)sharedInstance{
    static EOCClass *sharedInstance = nil;
    @synchronized(self){
        if(!sharedInstance){
            sharedInstance = [[self alloc] init];
        }
    }
    return sharedInstance;
}

@end

GCD引入了一項特性,使單例實現起來更為容易

@implementation EOCClass

+ (id)sharedInstance{
    static EOCClass *sharedInstance = nil;
    // 每次呼叫都必須使用完全相同的標誌,需要將標誌宣告為static
    static dispatch_once_t onceToken;
    // 只會進行一次
    dispatch_once(&onceToken, ^{
        sharedInstance = [[self alloc] init];    
    });
    return sharedInstance;
}

@end

dispatch_once可以簡化程式碼並徹底保證執行緒安全,所有問題都由GCD在底層處理。而且dispatch_once更高效,它沒有使用重量級的同步機制。

第46條:不要使用dispatch_get_current_queue

使用GCD時,經常需要判斷當前程式碼正在哪個佇列上執行。dispatch_get_current_queue函式返回的就是當前正在執行程式碼的佇列。不過iOS與Mac OS X都已經將它廢除了,要避免使用它。

同步佇列操作有可能會發生死鎖:

_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NUll);

- (NSString*)someString{
    __block NSString *localSomeString;
    dispatch_sync(_syncQueue, ^{
        localSomeString = _someString;
    });
    return localSomeString;
}

- (void)setSomeString:(NSString*)someString{
    dispatch_sync(_syncQueue, ^{
        _someString = someString;
    });
}

假如呼叫getter方法的佇列恰好是同步操作所針對的佇列(_syncQueue),那麼dispatch_sync就會一直不返回,直到塊執行完畢。可是應該執行塊的那個目標佇列卻是當前佇列,它又一直阻塞,等待目標佇列將塊執行完畢。這樣一來就出現死鎖。

這時候或許可以用dispatch_get_current_queue來解決(在這種情況下,更好的做法是確保同步操作所用的佇列絕不會訪問屬性)

- (NSString*)someString{
    __block NSString *localSomeString;
    dispatch_block_t block = ^{ 
        localSomeString = _someString; 
    };
    // 執行塊的佇列如果是_syncQueue,則不派發直接執行塊
    if (dispatch_get_current_queue() == _syncQueue) {
        block();
    }
    else{
        dispatch_sync(_syncQueue, block);
    }
    return localSomeString;
}

但是這種做法只能處理一些簡單的情況,如果遇到下面情況,仍有死鎖風險:

dispatch_queue_t queueA = dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB = dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);

dispatch_sync(queueA, ^{
    dispatch_sync(queueB, ^{
        dispatch_sync(queueA, ^{
                // 死鎖
        });
    });
});

因為這個操作是針對queueA的,所以必須等最外層的dispatch_sync執行完畢才行,而最外層的dispatch_sync又不可能執行完畢,因為它要等到最內層的dispatch_sync執行完畢,於是就死鎖了。

如果嘗試用dispatch_get_current_queue來解決:

dispatch_sync(queueA, ^{
    dispatch_sync(queueB, ^{
        dispatch_block_t block = ^{ /* ... */ };
        if (dispatch_get_current_queue() == queueA) {
            block();
        }
        else{
            dispatch_sync(queueA, block);
        }
    });
});

這樣做仍然會死鎖,因為dispatch_get_current_queue()返回的是當前佇列,即queueB,這樣的話仍然會執行鍼對queueA的同步派發操作,於是同樣會死鎖。

由於派發佇列是按層級來組織的,這意味著排在某條佇列中的塊會在其上級佇列裡執行。佇列間的層級關係會導致檢查當前佇列是否為執行同步派發所用的佇列這種方法並不總是奏效。

要解決這個問題,最好的辦法是通過GCD提供的功能來設定佇列特有資料。此功能可以把任意資料以鍵值對的形式關聯到佇列裡。最重要的是,假如獲取不到關聯資料,那麼系統會沿著層級體系向上查詢,直至找到資料或到達根佇列為止。

dispatch_queue_t queueA = dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB = dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);

static int kQueueSpecific;
CFStringRef queueSpecificValue = CFSTR("queueA");
// 在queueA上設定佇列特定值
dispatch_queue_set_specific(queueA, &kQueueSpecific, (void*)queueSpecificValue, (dispatch_function_t)CFRelease);

dispatch_sync(queueB, ^{
    dispatch_block_t block = ^{ NSLog(@"No deadlock!"); };
    CFStringRef retrievedValue = dispatch_get_specific(&kQueueSpecific);
    if (retrievedValue) {
        block();
    }
    else{
        dispatch_sync(queueA, block);
    }
});

dispatch_queue_set_specific函式首個引數為待設定佇列,其後兩個引數是鍵和值(函式按指標值來比較鍵,而不是其內容)。最後一個引數是解構函式。dispatch_function_t型別定義如下:

typedef void (*dispatch_function_t)(void*)

本例傳入CFRelease做引數,用以清理舊值。

佇列特定資料所提供的這套簡單易用的機制,避免了使用dispatch_get_current_queue時經常遇到的陷阱。但是,可以在除錯程式時使用dispatch_get_current_queue這個已經廢棄的方法,只是別把它編譯到發行版程式裡就行。

相關文章