《Effective Objective-C 2.0》6.塊與大中樞派發

第6章 塊與大中樞派發

block、塊、Block 塊、Block 對象在大多數 Objective-C 文檔中語義相同。

block 是一種可在 C、C++ 及 Objective-C 代碼中使用的“詞法閉包"(lexical closure),借由此機制,開發者可將代碼像對象一樣傳遞,令其在不同環境(context)下運行。還有個關鍵的地方是,在定義 block 的范圍內,它可以訪問到其中的全部變量。

GCD 是一種與 block 有關的技術,它提供了對線程的抽象,而這種抽象則基于“派發隊列” (dispatch queue) 。開發者可將塊排入隊列中,由GCD負責處理所有調度事宜。GCD會根據系統資源情況,適時地創建、復用、摧毀后臺線程(background thread),以便處理每個隊列。 此外,使用GCD還可以方便地完成常見編程任務,比如編寫 “只執行一次的線程安全代碼” (thread-safe single-code execution),或者根據可用的系統資源來并發執行多個操作。

第37條:理解“塊”這一概念

塊的基礎知識

簡單的 block:

^{
    //Block implementation here
};

block 語法結構:

typedef returnType(^name)(arguments);

定義一個名為 someBlock 的變量:

void (^someBlock)() = ^{
    //Block implementation here
};

block 的強大之處在于,在 block 內部可以訪問 block 外部變量:

// 將變量聲明為 __block 之后才可以在 Block 內部對此變量進行修改
__block int additional = 5;
// 聲明Block塊
int (^addBlock)(int a, int b) = ^(int a, int b) {
    additional = 10;
    return a + b + additional;
};
// 使用Block塊
int add = addBlock(2, 5);
  • 如果block所捕獲的變量是對象類型,那么就會自動保留它。系統在釋放這個塊的時候,也會將其一并釋放。
  • block本身可視為對象,它也有引用計數。
  • 如果將block定義在 Objective-C 類的實例方法中,那么除了可以訪問類的所有實例變童之外,還可以使用 self 變量。block總能修改實例變量,所以在聲明時無須加 _block。不過,如果通過讀寫操作捕獲了實例變量,那么也會自動把 self 變量一并捕獲,因為實例變量是與self所指代的實例關聯在一起的。
- (void)anInstanceMethod {
    //...
    void (^someBlock)() = ^{
        _anInstanceVariable = @"Something";
        NSLog(@"_anInstanceVariable = %@",_anInstanceVariable);
    };
    //...
}
  • 在block中,直接訪問實例變量和通過 self 來訪問該實例變量是等效的。
  • self 也是個對象,因而 block 在捕獲它時也會將其保留。如果 self 所指代的那個對象同時也保留了塊,那么這種情況通常就會導致引用循環

塊的內部結構

block對象在棧中的結構:

對應的結構體定義:

struct Block_descriptor {
    unsigned long int reserved;
    unsigned long int size;
    void (*copy)(void *dst, void *src);
    void (*dispose)(void *);
};

struct Block_layout {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    struct Block_descriptor *descriptor;
    /* Imported variables. */
};
  • isa指針:指向表明該block類型的類。
  • flags:按bit位表示一些block的附加信息,比如判斷block類型、判斷block引用計數、判斷block是否需要執行輔助函數等。
  • reserved:保留變量,我的理解是表示block內部的變量數。
  • invoke:函數指針,指向具體的block實現的函數調用地址。
  • descriptor:block的附加描述信息,比如保留變量數、block對象的大小、進行copydispose的輔助函數指針。
  • variables:即捕獲到的變量,因為block有閉包性,所以可以訪問block外部的局部變量。這些variables就是復制到結構體中的外部局部變量或變量的地址。

全局塊、棧塊及堆塊

  • 雖然 block 是對象,但是其所占的內存區域是分配在中的。這就是說,塊只在定義它的那個范圍內有效。
  • 給 block 對象發送 copy 消息可以把 Block 從復制到。拷貝后的塊,可以在定義它的那個范圍之外使用。
void (^block)();
if (/** condition */) {
    block = [^{
        NSLog(@"Block A");
    } copy];
}else {
    block = [^{
        NSLog(@"Block B");
    } copy];
}
block();
  • 全局塊(global block)不會捕捉任何狀態(比如外圍的變量等),運行時也無須有狀態來參與。塊所使用的整個內存區域,在編譯期已經完全確定了,因此,全局塊可以聲明在全局內存里,而不需要在每次用到的時候于棧中創建。另外,全局塊的拷貝操作是個空操作,因為全局塊決不可能為系統所回收。這種塊實際上相當于單例。下面就是個全局塊:
void (^block) () = ^{
    NSLog(@"This is a block");
);
  • 全局的靜態 block: _NSConcreteGlobalBlock類型的block要么是空block,要么是不訪問任何外部變量的block。它既不在棧中,也不在堆中,我理解為它可能在內存的全局區。
  • 保存在棧中的block:_NSConcreteStackBlock類型的block有閉包行為,也就是有訪問外部變量,并且該block只且只有有一次執行,因為棧中的空間是可重復使用的,所以當棧中的block執行一次之后就被清除出棧了,所以無法多次使用。
  • 保存在堆中的block:_NSConcreteMallocBlock類型的block有閉包行為,并且該block需要被多次執行。當需要多次執行時,就會把該block從棧中復制到堆中,供以多次執行。

要點

  • Clang 是開發 Mac OS X 及 iOS 程序所用的編譯器。
  • block 塊是 C、C++、Objective-C 中的詞法閉包。
  • block 塊可以接收參數,也可以有返回值。
  • block 塊可以分配在棧或堆上,也可以是全局的。分配在棧上的 block 塊可拷貝到堆里,這樣的話,就和標準的 Objective-C 對象一樣,具備引用計數了。

第38條:為常用的塊類型創建 typedef

代碼塊便捷寫法:typedefBlock

typedef <#returnType#>(^<#name#>)(<#arguments#>);

示例一:

// 定義 Block 塊
typedef int(^EOCSomeBlock)(BOOL flag, int value);

// 使用 Block 塊
EOCSomeBlock blcok = ^(BOOL flag, int value) {
    // Implementation
};

示例二:

typedef void(^EOCCompletionHandler)(NSData *data, NSError *error);

// 方法使用 Block 塊作為參數
- (void)startingWithCompletionHandler:(EOCCompletionHandler)completion;

使用 typedef 類型定義還便于重構 block 的類型簽名。

// 新增一個參數,用以表示完成任務所花的時間
typedef void(^EOCCompletionHandler)
        (NSData *data, NSTimeInterval duration, NSError *error);

要點

  • typedef 重新定義 block 類型,可以令 block 變量用起來更加簡單。
  • 定義新類型時應遵循現有的命名習慣,勿使其名稱與別的的類型相沖突。
  • 不妨為同一個 block 簽名定義多個類型別名。如果要重構的代碼使用了 block 類型的某個別名,那么只需修改相應的 typedef 中的 block 簽名即可,無需改動其他 typedef

第39條:用 handler 塊降低代碼分散程度

為用戶界面編碼時,一種常用的范式就是“異步執行任務”(perform task asynchronously)。這種范式的好處在于:處理用戶界面的顯示及觸摸操作所用的線程,不會因為要執行I/O或網絡通信這類耗時的任務而阻塞。這個線程通常稱為主線程(main thread)。

異步方法在執行完任務之后,需要以某種手段通知相關代碼。實現此功能有很多辦法。常用的技巧是設計一個委托協議(參見第23條),令關注此事件的對象遵從該協議。對象成為delegate之后,就可以在相關事件發生時(例如某個異步任務執行完畢時)得到通知了。

Delegate 模式:

#import <Foundation/Foundation.h>
@class EOCNetworkFetcher;

@protocol EOCNetworkFetcherDelegate <NSObject>
- (void)networkFetcher:(EOCNetworkFetcher *)fetcher
        didFinishWithData:(NSData *)data;
@end

@interface EOCNetworkFetcher : NSObject
@property (nonatomic, weak) id<EOCNetworkFetcherDelegate> delegate;
- (instancetype)initWithURL:(NSURL *)url;
- (void)start;
@end

其他類則可以像下面這樣使用此類所提供的 API 并遵守實現相應的 delegate 協議:

- (void)fetchFooData {
    NSURL *url = [[NSURL alloc] initWithString:
                  @"https://www.pinterest.com"];
    EOCNetworkFetcher *fetcher =
        [[EOCNetworkFetcher alloc] initWithURL:url];
    fetcher.delegate = self;
    [fetcher start];
}

- (void)networkFetcher:(EOCNetworkFetcher *)fetcher didFinishWithData:(NSData *)data {
    // deal with data
}

block 模式:

#import <Foundation/Foundation.h>

typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);

@interface EOCNetworkFetcher : NSObject
- (instancetype)initWithURL:(NSURL *)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler;
@end

其他類獲取數據:

- (void)fetchFooData {
    NSURL *url = [[NSURL alloc] initWithString:
                  @"https://www.pinterest.com"];
    EOCNetworkFetcher *fetcher =
        [[EOCNetworkFetcher alloc] initWithURL:url];
    // 調用 start 方法時直接以內聯形式定義 Completion Handler。
    [fetcher startWithCompletionHandler:^(NSData *data) {
        // deal with data
    }];
}

相比于委托模式,block 模式可以使代碼更清晰整潔、API 更緊致、邏輯關聯性更強。

委托模式有個缺點:如果類要分別使用多個獲取器下載不同數據,那么就得在 delegate 回調方法里根據傳入的獲取器參數來切換。

而使用 block 來寫的好處是:無須保存獲取器,也無須在回調方法里切換。每個 completion handler 的業務邏輯都是和相關的獲取器對象一起來定義的。

1. 分別用兩個處理程序來處理操作失敗和操作成功

這種 API 設計風格很好,由于成功和失敗的情況要分別處理,所以調用此 API 的代碼也就會按照邏輯,把應對成功和失敗情況的代碼分開來寫,這將令代碼更易讀懂。而且,若有需要,還可以把處理失敗情況或成功情況所用的代碼省略。

#import <Foundation/Foundation.h>

typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
typedef void(^EOCNetworkFetcherErrorHandler)(NSError *error);

@interface EOCNetworkFetcher : NSObject
- (instancetype)initWithURL:(NSURL *)url;
- (void)startWithCompletionHandler:
            (EOCNetworkFetcherCompletionHandler)handler
                    failureHandler:
            (EOCNetworkFetcherErrorHandler)failure;
@end

// 其他類使用:
- (void)fetchFooData {
    NSURL *url = [[NSURL alloc] initWithString:
                  @"https://www.pinterest.com"];
    EOCNetworkFetcher *fetcher =
        [[EOCNetworkFetcher alloc] initWithURL:url];
    [fetcher startWithCompletionHandler:^(NSData *data) {
        // deal with data
        
    } failureHandler:^(NSError *error) {
        // deal with error
        
    }];
}
2. 把處理失敗所需代碼與處理成功所用代碼,都封裝到同一個 completion handle 塊里

缺點:由于全部邏輯都寫在一起,導致塊代碼冗長復雜。

優點:能把所有業務邏輯都放在一起使其更加靈活。例如,在傳入錯誤信息時,可以把數據也傳進來。有時數據正下載到一半,突然網絡故障了。在這種情況下,可以把數據及相關的錯誤都回傳給塊。這樣的話,completion handler 就能據此判斷問題并適當處理了,而且還可利用已下載好的這部分數據做些事情。

總體來說,筆者建議使用同一個塊來處理成功與失敗情況。

#import <Foundation/Foundation.h>

typedef void(^EOCNetworkFetcherCompletionHandler)
                                (NSData *data, NSError *error);

@interface EOCNetworkFetcher : NSObject
- (instancetype)initWithURL:(NSURL *)url;
- (void)startWithCompletionHandler:
            (EOCNetworkFetcherCompletionHandler)handler;
@end

// 其他類使用:
- (void)fetchFooData {
    NSURL *url = [[NSURL alloc] initWithString:
                  @"https://www.pinterest.com"];
    EOCNetworkFetcher *fetcher =
        [[EOCNetworkFetcher alloc] initWithURL:url];
    // 需要在塊代碼中檢測傳人的error變量,并且要把所有邏輯代碼都放在一處
    [fetcher startWithCompletionHandler:^(NSData *data, NSError *error) {
        if (error) {
            // handle failure
        }else {
            // handle success
        }
    }];
}

基于 handler 來設計API還有個原因,就是某些代碼必須運行在特定的線程上,比如,Cocoa 與 Cocoa Touch 中的 UI 操作必須在主線程上執行:

// NSNotificationCenter
- (id <NSObject>)addObserverForName:(nullable NSNotificationName)name
                             object:(nullable id)obj
                              queue:(nullable NSOperationQueue *)queue
                         usingBlock:(void (^)(NSNotification *note))block;

要點

  • 在創建對象時,使用內聯的 handler 塊將相關業務邏輯一并聲明。
  • 在有多個實例需要監控時,如果采用委托模式,那么經常需要根據傳入的對象來切換,而若改用 handler 塊來實現,則可直接將 block 與相關對象放在一起。
  • 設計 API 時如果用到了 handler 塊,那么可以增加一個參數,使調用者可通過此參數來決定應該把 block 安排在哪個隊列上執行。

第40條:用塊引用其所屬對象時不要出現保留環

使用 block 很容易導致循環引用:

//  EOCNetworkFetcher.h
#import <Foundation/Foundation.h>

typedef void(^EOCNetworkFetcherCompletionHandler)
                                (NSData *data);

@interface EOCNetworkFetcher : NSObject
@property (nonatomic, strong, readonly) NSURL *url;
- (instancetype)initWithURL:(NSURL *)url;
- (void)startWithCompletionHandler:
            (EOCNetworkFetcherCompletionHandler)completion;
@end

//  EOCNetworkFetcher.m
#import "EOCNetworkFetcher.h"

@interface EOCNetworkFetcher ()
@property (nonatomic, strong, readwrite) NSURL *url;
@property (nonatomic, copy)
            EOCNetworkFetcherCompletionHandler completionHandler;
@property (nonatomic, strong) NSData *downloadData;
@end

@implementation EOCNetworkFetcher

- (instancetype)initWithURL:(NSURL *)url {
    if (self = [super init]) {
        _url = url;
    }
    return self;
}

- (void)startWithCompletionHandler:
        (EOCNetworkFetcherCompletionHandler)completion {
    self.completionHandler = completion;
    // 開啟網絡請求
    // 設置 downloadData 屬性
    // 下載完成后,以回調方式執行 Block
    [self p_requestCompleted];
}

// 為了能在下載完成后通過 p_requestCompleted 方法執行調用者所指定的塊,
// 需要把 completion handler 保存到實例變量
// ?? _networkFetcher → _completionHandler
- (void)p_requestCompleted {
    if (_completionHandler) {
        _completionHandler(_downloadData);
    }
}

@end

// 某個類可能會創建以上網絡數據獲取器對象,并用其從URL中下載數據:
@implementation EOCDataModel {
    // ?? EOCDataModel → _networkFetcher
    EOCNetworkFetcher *_networkFetcher;
    NSData *_fetchedData;
}

- (void)downloadData {
    NSURL *url = [[NSURL alloc] initWithString:@"http://www.example.com"];
    _networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
    [_networkFetcher startWithCompletionHandler:^(NSData *data) {
        
        NSLog(@"Request URL %@ finished",_networkFetcher.url);
        // 因為 completion handler 塊要設置 _fetchedData 實例變量,所以它必須捕獲 self 變量,而 self 指向 EOCDataModel 類
        // ?? _completionHandler → EOCDataModel
        _fetchedData = data;
      
        // ??等 completion handler 塊執行完畢后,再打破保留環,以便使獲取器對象在handler 塊執行期間保持存活狀態。
        _networkFetcher = nil;
    }];
}

問題:

  • 在上例中,唯有 completion handler 運行過后,方能解除保留環。若是completion handler—直不運行,那么保留環就無法打破,于是內存就會泄漏。
  • 如果 completion handler 塊所引用的對象最終又引用了這個塊本身,那么就會出現保留環。
- (void)downloadData {
    NSURL *url = [[NSURL alloc] initWithString:
                  @"http://www.example.com"];
    EOCNetworkFetcher *networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
    [networkFetcher startWithCompletionHandler:^(NSData *data) {
        // completionHandler → networkFetcher.url
        // networkFetcher → completionHandler
        NSLog(@"Request URL %@ finished",networkFetcher.url);
        _fetchedData = data;
    }];
}

解決方法:獲取器對象之所以要把 completion handler 塊保存在屬性里面,其唯一目的就是想稍后使用這個塊。可是,獲取器一旦運行過 completion handler 之后,就沒有必要再保留它了。所以,只需將 p_requestCompleted 方法按如下方式修改即可:

- (void)p_requestCompleted {
    if (_completionHandler) {
        _completionHandler(_downloadData);
    }
    self.completionHandler = nil; // ??
}

要想清楚塊可能會捕獲并保留哪些對 象。如果這些對象又直接或間接保留了塊,那么就要考慮怎樣在適當的時機解除保留環。

要點

  • 如果 block 所捕獲的對象直接或間接的保留了 block 本身,那么就得當心循環引用的問題。
  • 一定要找個適當的時機解除循環引用,而不能把責任推給 API 的調用者。

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

使用同步鎖實現同步機制:

  1. @synchronized 同步塊:

    - (void)synchronizedMethod {
        @synchronized (self) {
            // ...
            // 根據給定對象,自動創建一個鎖,并等待塊中的代碼執行完畢。
            // 濫用 @synchronized (self) 會降低代碼效率
            
        }   // 執行到這段代碼結尾處,釋放鎖。
    }
    
  2. NSRecursiveLock 遞歸鎖

    • 線程能夠多次持有該鎖, 而不會出現死鎖(deadlock)現象。
    • 在極端情況下,同步塊會導致死鎖。

GCD:串行并發隊列

@implementation HQLBlockObject {
    dispatch_queue_t _syncQueue;
}

    // 自定義并發隊列
    // ??注意到,文章中此處作者使用的是全局并發隊列,而在 Ray Wenderlich 的GCD系列教程中使用的是自定義并發隊列:
    // 原因在于:全局隊列中還可能有其他任務正在執行,一旦加鎖就會阻塞其他任務的正常執行,因此我們開辟一個新的自定義并發隊列專門處理這個問題。
    _syncQueue = dispatch_queue_create("com.effectiveobjectivec.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;
    })
}

把設置操作與獲取操作都安排在序列化的隊列里執行,這樣的話,所有針對屬性的訪問操作就都同步了。

柵欄塊

在隊列中,柵欄塊必須單獨執行,不能與其他塊并行。這只對并發隊列有意義,因為串行隊列中的塊總是按順序逐個來執行的。并發隊列如果發現接下來要處理的塊是個柵欄塊 (barrier block) ,那么就一直要等當前所有并發塊都執行完畢,才會單獨執行這個柵欄塊。待柵欄塊執行過后,再按正常方式繼續向下處理。

dispatch_barrier_sync(dispatch_queue_t  _Nonnull queue, ^{

})
dispatch_barrier_async(dispatch_queue_t  _Nonnull queue, ^{

})
- (NSString *)someString {
    // 后臺執行
    _syncQueue = dispatch_get_global_queue(0, 0);
    
    __block NSString *localSomeString;
    // 同步后臺隊列
    dispatch_sync(_syncQueue, ^{
        localSomeString = _someString;
    });
    return localSomeString;
}

- (void)setSomeString:(NSString *)someString {
    // 異步柵欄隊列
    dispatch_barrier_async(_syncQueue, ^{
       _someString = someString;
    });
}

要點

  • 派發隊列可用來表述同步語義,這種做法要比使用 @synchronized 塊或 NSLock 對象更簡單。
  • 將同步與異步派發結合起來,可以實現與普通加鎖機制一樣的同步行為,而這么做卻不會阻塞執行異步派發的線程。
  • 使用同步隊列及柵欄塊,可以令同步行為更加高效。

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

// 接受的參數就是要執行的選擇子
- (id)performSelector:(SEL)aSelector;

該方法與直接調用選擇子等效:

[self performSelector:@selector(selectorMethod)];
[self selectorMethod];

特點:編譯器要等到運行期才能知道執行的選擇子。可以在動態綁定之上再次使用動態綁定,因而可以實現出下面這種功能:

SEL selector;
if ( /** condition A */ ) {
    selector = @selector(foo);
}else if ( /** condition B */ ) {
    selector = @selector(bar);
}else {
    selector = @selector(baz);
}
[self performSelector:selector];

不推薦使用 performSelector 方法的原因:

?? ARC 下會引起內存泄漏:編譯器并不知道將要執行的選擇子是什么,因此,也就不了解其方法簽名及返回值,甚至連是否有返回值都不清楚,而由于編譯器不知道方法名,所以就沒辦法運用ARC的內存管理規則來判定返回值是不是應該釋放。鑒于此,ARC采用了比較謹慎的做法,就是不添加釋放操作。然而這么做可能導致內存泄漏,因為方法在返回對象時可能已經將其保留了。

即使使用靜態分析器,也很難偵測到隨后的內存泄漏。

??返回值只能是 void 或對象類型,performSelector 方法的返回值類型是 id。如果想返回整數或浮點數等類型的值,那么就需要執行一些復雜的轉換操作了,而這種轉換很容易出錯。

如果返冋值的類型為C語言結構體,則不可以使用 performSelector 方法。

??performSelector 方法還有諸多局限性,傳入的參數類型必須是對象類型且最多只能接受2個參數、具備延后執行的方法無法處理帶有2個參數的選擇子。

下面是幾個使用 Block 的替代方案:

延后執行

?推薦:

double delayInSeconds = 5.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW,
                                        delayInSeconds *NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^{
    [self doSomething];
});

?反對:

[self performSelector:@selector(doSomething)
             withObject:nil
             afterDelay:5.0];

主線程執行

?推薦:

// 同步主線程(waitUntilDone:YES)
dispatch_sync(dispatch_get_main_queue(), ^{
    [self doSomething];
});

// 異步主線程(waitUntilDone:NO)
dispatch_async(dispatch_get_main_queue(), ^{
    [self doSomething];
});

?反對:

[self performSelectorOnMainThread:@selector(doSomething)
                       withObject:nil
                    waitUntilDone:NO];

要點

  • performSelector 系列方法在內存管理方面容易有疏失。它無法確定將要執行的 selector 具體是什么,因而ARC編譯器就無法插入適當的內存管理方法。
  • performSelector 系列方法所能處理的 selector 太過局限了,selector 的返回值類型及發送給方法的參數個數都受到限制。
  • 如果想把任務放在另一個線程上執行,那么最好不要用 performSelector 系列方法而是應該把任務封裝到 block 里然后調用 GCD 機制的相關方法來實現。

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

GCD & NSOperation

  • GCD 是純 C 的 API,而 NSOperation(操作隊列)則是 Objective-C 的對象。

  • 在GCD中,任務用 Block 來表示,而 Block 是個輕量級數據結構(參見第37條)。與之相反,“操作"(operation) 則是個更為重量級的 Objective-C 對象

  • 用 NSOperationQueue 類的 addOperationWithBlock: 方法搭配 NSBlockOperation 類來使用操作隊列,其語法與純 GCD 方式非常類似。使用 NSOperation 及 NSOperationQueue 的好處如下:

    • 取消某個操作。運行任務之前, 可以在 NSOperation 對象上調用 cancel 方法取消任務執行。
    • 指定操作間的依賴關系。
    • 通過鍵值觀測機制(簡稱 KVO)監控 NSOperation 對象的屬性。
    • 指定操作的優先級。操作的優先級表示此操作與隊列中其他操作之間的優先關 系。優先級高的操作先執行,優先級低的后執行。
    • 重用 NSOperation 對象。
  • 操作隊列有很多地方勝過派發隊列。操作隊列提供了多種預設的執行任務的方式,開發者可以直接使用。

  • 有一個API選用了操作隊列而非派發隊列,這就是 NSNotificationCemer,開發者可通過其中的方法來注冊監聽器,以便在發生相關事件時得到通知,而這個方法接受的參數是塊,不是選擇子。

    - (id <NSObject>)addObserverForName:(nullable NSNotificationName)name
                                 object:(nullable id)obj
                                  queue:(nullable NSOperationQueue *)queue
                             usingBlock:(void (^)(NSNotification *note))bloc;
    
  • 應該盡可能選用高層API,只在確有必要時才求助于底層。筆者也同意這個說法,但我并不盲從。某些功能確實可以用高層的Objective-C方法來做,但這并不等于說它就一定比底層實現方案好。要想確定哪種方案更佳,最好還是測試一下性能。

要點

  • 在解決多線程與任務管理問題時,派發隊列并非唯一方案。
  • 操作隊列提供了一套高層的 Objective-C API,能實現純 GCD 所具備的絕大部分功能,而且還能完成一些更為復雜的操作,那些操作若改用GCD來實現,則需另外編寫代碼。

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

dispatch group 是 GCD 的一項特性,能夠把任務分組。調用者可以等待這組任務執行完畢,也可以在提供回調函數之后繼續往下執行,這組任務完成時,調用者會得到通知。這個功能有許多用途,其中最重要、最值得注意的用法,就是把將要并發執行的多個任務合為一組,于是調用者就可以知道這些任務何時才能全部執行完畢。比方說,可以把壓縮一系列文件的任務表示成 dispatch group

創建及使用 dispatch group

dispatch_group_t group = dispatch_group_create();
dispatch_group_async(group, dispatch_get_global_queue(0, 0), ^{
    // 并行執行的線程一
});
dispatch_group_async(group, dispatch_get_global_queue(0, 0), ^{
    // 并行執行的線程二
});
dispatch_group_notify(group, dispatch_get_global_queue(0, 0), ^{
   // 匯總結果
});

給任務編組的兩種方法:

// 1.比 dispatch_async 多一個參數,用于表示待執行的塊所歸屬的組
dispatch_group_async(dispatch_group_t group,
                     dispatch_queue_t queue,
                     dispatch_block_t block);

// 2.dispatch_group_enter、dispatch_group_leave 需要成對使用
dispatch_group_enter(dispatch_group_t group); // 使分組里正要執行的任務數遞增,
dispatch_group_leave(dispatch_group_t group); // 使分組里正要執行的任務數遞減.

dispatch_group_wait 函數用于等待 dispatch group 執行完畢:

dispatch_group_wait(dispatch_group_t group, 
                    dispatch_time_t timeout);

示例:

令數組中的每個對象都執行某項任務,并且等待所有任務執行完畢:

dispatch_queue_t queue =
    dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
for (id object in collection) {
    dispatch_group_async(group, queue, ^{
        [object performTask];
    });
}

dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
// 任務執行完畢后繼續操作

// 若當前線程不應阻塞,則可以使用 dispatch_group_notify 代替 dispatch_group_wait
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(group, notifyQueue, ^{
    // 任務執行完畢后繼續操作
});

開發者未必總需要使用 dispatch group。有時候采用單個隊列搭配標準的異步派發,也可實現同樣效果:

// 自定義串行隊列
dispatch_queue_t queue =
    dispatch_queue_create("com.effecitveobjectivec.queue", NULL);
for (id object in collection) {
    dispatch_async(queue, ^{
        [object performTask];
    });
}
dispatch_async(queue, ^{
    // 任務執行完畢后繼續操作
});

根據系統資源狀況來執行任務:

為了執行隊列中的塊,GCD會在適當的時機自動創建新線程或復用舊線程。如果使用并發隊列,那么其中有可能會有多個線程,這也就意味著多個塊可以并發執行。在并發隊列中,執行任務所用的并發線程數量,取決于各種因素,而GCD主要是根據系統資源狀況來判定這些因素的。假如CPU有多個核心,并且隊列中有大量任務等待執行,那么GCD就可能會給該隊列配備多個線程。通過dispatch group所提供的這種簡便方式,既可以并發執行一系列給定的任務,又能在全部任務結束時得到通知。由于GCD 有并發隊列機制,所以能夠根據可用的系統資源狀況來并發執行任務。而開發者則可以專注于業務邏輯代碼,無須再為了處理并發任務而編寫復雜的調度器。

遍歷某個 collection ,并在其每個元素上執行任務,而這也可以用另外一個 GCD 函數來實現:

dispatch_apply(size_t iterations,
               dispatch_queue_t queue,
               void (^block)(size_t));

此函數會將塊反復執行一定的次數,每次傳給塊的參數值都會遞增,從0開始,直至 “ iterations-1 ”。

// 自定義串行隊列
dispatch_queue_t queue =
    dispatch_queue_create("com.effecitveobjectivec.queue", NULL);    
dispatch_apply(10, queue, ^(size_t) {
    // Perform Task:0~9
});

與 for 循環不同的是, dispatch_apply 所用的隊列可以是并發隊列。但是 dispatch_apply 會持續阻塞,直到所有任務都執行完畢為止。

假如把塊派給了當前隊列(或者體系中高于當前隊列的某個串行隊列),就將導致死鎖。若想在后臺執行任務,則應使用 dispatch group

要點

  • 一系列任務可歸入一個 dispatch group 之中。開發者可以在這組任務執行完畢時獲得通知。
  • 通過 dispatch group,可以在并發式派發隊列里同時執行多項任務。此時GCD會根據系統資源狀況來調度這些并發執行的任務。開發者若自己來實現此功能,則需編寫大量代碼。

第45條:使用 dispatch_once 來執行只需運行一次的線程安全代碼

單例模式:

+ (instancetype)sharedInstance {
    static EOCClass *sharedInstance = nil;
    // 為保證線程安全,將創建單例實例的代碼包裹在同步塊里。
    @synchronized (self) {
        if (!sharedInstance) {
            sharedInstance = [[self alloc] init];
        }
    }
    return sharedInstance;
}

dispatch_once 函數:

_dispatch_once(dispatch_once_t *predicate,
               dispatch_block_t block)

該函數保證相關的塊必定會執行,且僅執行一次。首次調用該函數時,必然會執行塊中的代碼,最重要的一點在于,此操作完全是線程安全的

+ (instancetype)sharedInstance {
    static EOCClass *sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}
  • 使用 dispatch_once 可以簡化代碼并且徹底保證線程安全。
  • 由于每次調用時都必須使用完全相同的標記, 所以標記要聲明成 static。把該變量定義在static 作用域中,可以保證編譯器在每次執行 sharedlnstance 方法時都會復用這個變量,而不會創建新變量。
  • 此外,dispatch_once 更高效。 @synchronized 使用了重量級的同步機制,每次運行代碼前都要獲取鎖。而 dispatch_once 采用原子訪問(atomic access)來查詢標記,以判斷其所對應的代碼原來是否已經執行過。

要點

  • 經常需要編寫只需執行一次的線程安全代碼。通過 GCD 所提供的 dispatch_once 函數,很容易就能實現此功能。
  • 標記應該聲明在 staticglobal 作用域中,這樣的話,在把只需執行一次的 block 傳給dispatch_once 函數時,傳進去的標記也是相同的。

第46條:不要使用 dispatch_get_current_queue

// 此函數返回當前正在執行代碼的隊列。
// This function is deprecated and will be removed in a future release.
dispatch_get_current_queue(void);

Tips:iOS 系統從 6.0 版本起,已經正式棄用此函數了。

要點

  • dispatch_get_current_queue 函數的行為常常與開發者所預期的不同。此函數已廢棄,只應做調試之用。
  • 由于派發隊列是按層級來組織的,所以無法單用某個隊列對象來描述當前隊列這一概念。
  • dispatch_get_current_queue 函數用于解決由不可重入的代碼所引發的死鎖,然而能用此函數解決的問題,通常也能改用“隊列特定數據”來解決。

參考

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容