[OC学习笔记]块与大中枢开发

当前在开发应用程序时,每位程序员都应留意多线程问题。你可能会说自己要开发的应用程序用不到多线程,即便如此,它也很可能依然是多线程的,因为系统框架通常会在UI线程之外再使用一些线程来执行任务。开发应用程序时,最糟糕的事莫过于程序因UI线程阻塞而挂起了。在Mac OS X系统中,这将使鼠标指针一直呈现令人焦急的旋转彩球状;而在iOS系统中,阻塞过久的程序可能会终止执行。
所幸公司以全新方式设计了多线程。当前多线程编程的核心就是“块”(block)与“大中枢派发”(Grand Central Dispatch,GCD)。这虽然是两种不同的技术,但它们是一并引入的。“块”是一种可在C、C++及Obiective-C代码中使用的“词法闭包”(lexical closure),它极为有用,这主要是因为借由此机制,开发者可将代码像对象一样传递,令其在不同环境(context)下运行。还有个关键的地方是,在定义“块”的范围内,它可以访问到其中的全部变量。
GCD是一种与块有关的技术,它提供了对线程的抽象,而这种抽象则基于“派发队列”(dispatch queue)。开发者可将块排入队列中,由GCD负责处理所有调度事宜。GCD 会根据系统资源情况,适时地创建、复用、摧毁后台线程(background thread),以便处理每个队列此外,使用GCD还可以方便地完成常见编程任务,比如编写“只执行一次的线程安全代码(thread-safe single-code execution),或者根据可用的系统资源来并发执行多个操作。
块与GCD都是当前Objective-C编程的基石。因此,必须理解其工作原理及功能。

一、理解“块”这一概念

块可以实现闭包。这项语言特性是作为“扩展”(extension)而加GCC编译器中的,在近期版本的Clang中都可以使用(Clang是开发Mac OS X及iOS程序所用的编译器)。10.4版及其后的Mac OS X系统,与4.0版及其后的iOS系统中,都含有正常执行块所需的运行期组件。从技术上讲,这是个位于C语言层面的特性,因此,只要有支持此特性的编译器,以及能执行块的运行期组件,就可以在C、C++、Objective-C、Objective-C++代码中使用它。

(一)块的基础知识

块与函数类似,只不过是直接定义在另一个函数里的,和定义它的那个函数共享同一个范围内的东西。块用“^”符号来表示,后面跟着一对花括号,括号里面是块的实现代码。例如,下面就是个简单的块:

^{
	//Block implementation here
}

块其实就是个值,而且自有其相关类型。与intfloat或OC对象一样,也可以把块赋给变量,然后像使用其他变量那样使用它。块类型的语法与函数指针近似。下面列出的这个块很简单,没有参数,也不返回值:

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

这段代码定义了一个名为someBlock的变量。由于变量名写在正中间,所以看上去也许有点怪,不过一旦理解了语法,很容易就能读懂。块类型的语法结构如下:

return_type (^block name)(parameters)

下面这种写法所定义的块,返回int值,并且接受两个int做参数:

int (^addBlock)(int a, int b) = ^(int a, int b) {
	return a+b;
};

定义好之后,就可以像函数那样使用了。比方说,addBlock块可以这样用:

int add = addBlock(2,5); //add = 7

块的强大之处是:在声明它的范围里,所有变量都可以为其所捕获。这也就是说,那个范围里的全部变量,在块里依然可用。比如,下面这段代码所定义的块,就使用了块以外的变量:

int additional = 5;
int (^addBlock)(int a, int b) = ^(int a, int b){
	return a + b + additional;
};
int add = addBlock(2,5); //add = 12

默认情况下,为块所捕获的变量,是不可以在块里修改的。在本例中,假如块内的代码改动了additional变量的值,那么编译器就会报错。不过,声明变量的时候可以加上__block修饰符,这样就可以在块内修改了。例如,可以用下面这个块来枚举数组中的元素,以判断其中有多少个小于2的数:

NSArray *array = @[@0, @1@2@3@4@5]; 
__block NSInteger count = 0;
[array enumerateObjectsUsingBlock:
	^(NSNumber *number, NSUInteger idx, BOOL *stop) {
		if([number compare:@2] == NSOrderedAscending) { 
			count++;
		}
	}];
//count=2

这段范例代码也演示了“内联块”(inline block)的用法。传给“numerateObjects UsingBlock:”方法的块并未先赋给局部变量,而是直接内联在函数调用里了。由这种常见的编码习惯也可以看出块为何如此有用。在OC语言引人块这一特性之前,想要编出与刚才那段代码相同的功能,就必须传入函数指针或选择子的名称,以供枚举方法调用。状态必须手工传人与传出,这一般通过“不透明的void 指针”(opaque void pointer)实现,如此一来。就得再写几行代码了,而且还会令方法变得有些松散。与之相反,若声明内联形式的块,则可把所有业务逻辑都放在一处。
如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放。这就引出了一个与块有关的重要问题。块本身可视为对象。实际上,在其他OC对象所能响应的选择子中,有很多是块也可以响应的。而最重要之处则在于,块本身也和其他对象一样,有引用计数。当最后一个指向块的引用移走之后,块就回收了。回收时也会释放块所捕获的变量,以便平衡捕获时所执行的保留操作。
如果将块定义在OC类的实例方法中,那么除了可以访问类的所有实例变量之外,还可以使用self变量。块总能修改实例变量,所以在声明时无须加__block。不过,如果通过读取或写人操作捕获了实例变量,那么也会自动把self变量一并捕获了,因为实例变量是与self所指代的实例关联在一起的。例如,下面这个块声明在MyClass类的方法中:

@interface MyClass

- (void)anInstanceMethod {
	//...
	void (^someBlock)() = ^{
		_anInstanceVariable = @"Something";
		NSLog(@" anInstanceVariable==@", _anInstanceVariable);
	};
	//...
}

@end

如果某个MyClass实例正在执行anInstanceMethod方法,那么self变量就指向此实例。由于块里没有明确使用self变量,所以很容易就会忘记 self变量其实也为块所捕获了。直接访问实例变量和通过self来访问是等效的:

self->_anInstanceVariable = @"Something";

之所以要捕获self变量,原因正在于此。我们经常通过属性访问实例变量,在这种情况下,就要指明self了:

self.aProperty = @"Something";

然而,注意:self也是一个对象,因而块在捕获它时也会将其保留。如果self所指代的那个对象同时也保留了块,那么这种情况通常会导致“保留环”。

(二)块的内部结构

每个OC对象都占据着某个内存区域。因为实例变量的个数及对象所包含的关联数据互不相同,所以每个对象所占的内存区域也有大有小。块本身也是对象,在存放块对象的内存区域中,首个变量是指向Class对象的指针,该指针叫做isa。其余内存里含有块对象正常运转所需的各种信息。如图:
在这里插入图片描述
在内存布局中,最重要的就是invoke变量,这是个函数指针,指向块的实现代码。函原型至少要接受一个void*型的参数,此参数代表块。刚才说过,块其实就是一种代替函数指针的语法结构,原来使用函数指针时,需要用“不透明的void指针”来传递状态。而改用块之后,则可以把原来用标准C语言特性所编写的代码封装成简明且易用的接口。
descriptor变量是指向结构体的指针,每个块里都包含此结构体,其中声明了块对象的总体大小,还声明了copydispose这两个辅助函数所对应的函数指针。辅助函数在拷贝及丢弃块对象时运行,其中会执行一些操作,比方说,前者要保留捕获的对象,而后者则将之释放。
块还会把它所捕获的所有变量都拷贝一份。这些拷贝放在descriptor变量后面,捕获了多少个变量,就要占据多少内存空间。请注意,拷贝的并不是对象本身,而是指向这些对象的指针变量。invoke函数为何需要把块对象作为参数传进来呢?原因就在于,执行块时,要从内存中把这些捕获到的变量读出来。

(三)全局块、栈块及堆块

定义块的时候,其所占的内存是分配在中的。这就是说,块只在定义它的那个范围内有效,如下面的代码就有危险:

void (^block) ();
if (/**/) {
	block = ^{
		NSLog(@"Block A");
	};
} else {
	block = ^{
		NSLog(@"B;ock B");
	};
}
block();

定义在ifelse语句中的两个块都分配在栈内存中。编译器会给每个块分配好栈内存。然而等离开了相应的范围之后,编译器有可能把分配给块的内存覆写掉。于是,这两个块只能保证在对应的ifelse语句范围内有效。这样写出来的代码可以编译,但是运行起来时而正确,时而错误。若编译器未覆写待执行的块,则程序照常运行,若覆写,则程序崩溃。
为解决此问题,可给块对象发送copy消息以拷贝之。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象了。后续的复制操作都不会真的执行复制,只是递增块对象的引用计数。如果不再使用这个块,那就应将其释放,在ARC环境下会自动释放,而手动管理引用计数时则需要自己来调用release方法。当引用计数降为0后,“分配在堆上的块”(heap block)会像其他对象一样,为系统所回收。而“分配在栈上的块”(stackblock)则无须明确释放,因为栈内存本来就会自动回收,刚才那段范例代码之所以有危险,原因也在于此。
明白这一点后,我们只需给代码加上两个copy方法调用,就可令其变得安全了:
明白这一点了后,我们只需给代码加上两个copy方法调用,就可以令其变得安全了:

void (^block) ();
if (/**/) {
	block = [^{
		NSLog(@"Block A");
	} copy];
} else {
	block = [^{
		NSLog(@"B;ock B");
	} copy];
}
block();

现在代码就安全了。如果手动管理引用计数,那么在用完块后还需将其释放。
除了“栈块”和“堆块”之外,还有一类块叫做“全局块”(globalblock)。这种块不会捕捉任何状态(比如外围的变量等),运行时也无须有状态来参与。块所使用的整个内存区域,在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。另外,全局块的拷贝操作是个空操作,因为全局块决不可能为系统所回收。这种块实际上相当于单例。下面就是个全局块:

void (^block)() = ^{
	NSLog(@"This is a block");
};

由于运行该块所需的全部信息都能在编译期确定,所以可以把它做成全局块。这完全是种优化技术:若把如此简单的块当作复杂的块来处理,那就会在复制及丢弃该块时执行一些无谓的操作。

二、为常用的块类型创建typedef

每个块都具备其“固有类型”(inherent type),因而可将其赋给适当类型的变量。这个类型由块所接受的参数及其返回值组成。例如下面这个块:

^(BOOL flag, int value) {
	if (flag) {
		return value * 5;
	} else {
		return value * 10;
	}
}

此块接受两个类型分别为BOOLint的参数,并返回类型为int的值。如果想把它赋给变量,则需注意其类型。变量类型及相关赋值语句如下:

int (^variableName)(BOOL flag, int value) =
	^(BOOL flag, int value) {
		// Implementation
		return someInt;
	}

这个类型似乎和普通的类型大不相同,然而如果习惯函数指针的话,那么看上去就会觉得眼熟了。块类型的语法结构如下:

return_type (^block_name)(parameters)

与其他类型的变量不同,在定义块变量时,要把变量名放在类型之中,而不要放在右侧。这种语法非常难记,也非常难读。鉴于此,我们应该为常用的块类型起个别名,尤其是打算把代码发布成API供他人使用时,更应这样做。开发者可可以起个更为易读的名字来表示块的用途,而把块的类型隐藏在其后面。
为了隐藏复杂的块类型,需要用到C语言中名为“类型定定义”(type definition)的特性。typedef关键字用于给类型起个易读的别名。比方说,想定义人新类型,用以表示接受BOOLint参数并返回int值的块,可通过下列语句来做:

typedef int(^SomeBlock)(BOOL, int value);

声明变量时,要把名称放在类型中间,并在前面加上“^”符号,而定义新类型时也得这么做。上面这条语句向系统中新增了一个名为SomeBlock的类型。此后,不用再以复杂的块类型来创建变量了,直接使用新类型即可:

SomeBlock block = ^(BOOL flag, int value) {
	// Implementation
};

这次代码读起来就顺畅多了:与定义其他变量时一样,变量类型在左边,变量名在右边。
通过这项特性,可以把使用块的API做得更为易用些。类里面有些方法可能需要用块来做参数,比如执行异步任务时所用的“completion handler”(任务完成后所执行的处理程序)参数就是块,凡遇到这种情况,都可以通过定义别名使代码变得更为易读。比方说,类里有个方法可以启动任务,它接受一个块作为处理程序,在完成任务之后执行这个块。若不定义别名,则方法签名会像下面这样:

-(void)startWithCompletionHandler:
(void(^)(NSData *data, NSError *error))completion:

注意,定义方法参数所用的块类型语法,又和定义变量时不同。若能把方法签名中的参数类型写成一个词,那读起来就顺口多了。于是,可以给参数类型起个别名。然后使用此名称来定义:

typedef void(^CompletionHandler)(NSData *data, NSError *error);
-(void)startWithCompletionHandler:(CompletionHandler)completion;

现在参数看上去就简单多了。而且易于理解。当前,优秀的集成开发环境(Integrated Development Environment,IDE)都可以自动把类型定义展开,所以typedef这个功能变得很实用。
使用类型定义还有个好处,就是当你打算重构块的类型签名时会很方便。比方说,要给原来的completion handler块再加一个参数,用以表示完成任务所花的时间,那么只需修改类型定义语句即可:

typedef void(^CompletionHandler)(NSData *data, NSTimeInterval duration, NSError *error);

修改之后,凡是使用了这个类型定义的地方,比如方法签名等处,都会无法编译,而且报的是同一种错误,于是开发者可据此逐个修复。若不用类型定义,而直接写块类型,那么代码中要修改的地方就更多了。开发者很容易忘掉其中一两处,从而引发难于排查的bug。
最好在使用块类型的类中定义这些typedef,而且还应该把这个类的名字加在由typedef所定义的新类型名前面,这样可以闸明块的用途。还可以用typedef给同一个块签名类型创建数个别名。在这件事上,多多益善。
Mac OS X 与 iOS 的Accounts框架就是个例子。在该框架中可以找到下面这两个类型定义语句:

typedef void(^ACAccountStoreSaveCompletionHandler)
(BOoL success, NSError *error);
typedef void(^ACAccountstoreRequestAccessCompletionHandler)
(BOOL granted, NSError *error);

这两个类型定义的签名相同,但用在不同的地方。开发者看到类型别名及签名中的参数之后,很容易就能理解此类型的用途。它们本来也可以合并成一个typedef,比如叫做ACA-ccountStoreBooleanCompletionHandler,使用那两个别名的地方,都可以统一使用此名称。然而,这么做之后,块与参数的用途看上去就不那么明显了。
与此相似,如果有好几个类都要执行相似但各有区别的异步任务,而这几个类又不能放人同一个继承体系,那么,每个类就应该有自己的completion handler类型。这几个completion handler的签名也许完全相同,但最好还是在每个类里都各自定义一个别名,而不要共用同一个名称。反之,若这些类能纳入同一个继承中,则应该将类型定义语句放在超类中,以供各子类使用。

三、用handler块降低代码分散程度

为用户界面编码时,一种常用的范式就是“异步执行任务”(perform task asynchronously)。这种范式的好处在于:处理用户界面的显示及触摸操作所用的线程,不会因为要执行I/O或网络通信这类耗时的任务而阻塞。这个线程通常常称为主线程(main thread)。假设把执行异步任务的方法做成同步的,那么在执行任务时,用户界面就变得无法响应用户输入了。某些情况下,如果应用程序在一定时间内无响应,那么就会自动终止。iOS系统上的应用程序就是如此,“系统监控器”(system watchdog)在发现某个应用程序的主线程已经阻塞了一段时间之后,就会令其终止。
异步方法在执行完任务之后,需要以某种手段通知相关代码。实现此功能有很多办法。常用的技巧是设计一个委托协议,令关注此事件的对象遵从该协议。对象成为delegate之后,就可以在相关事件发生时(例如某个异步任务执行完毕时)得到通知了。
比方说,要写一个从 URL 中获取数据的类。使用委托模式设计出来的类会是这个样子:

#import <Foundation/Foundation.h>
@class NetworkFetcher;
@protocol NetworkFetcherDelegate <NSObject>

- (void)networkFetcher:(NetworkFetcher*)networkFetcher didFinishWithData:(NSData*)data;

@end

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

而其他类则可像下面这样使用此类所提供的API:

- (void)fetchFooData {
	NSURL *url = [[NSURL alloc] initwithstring:@"..."];
	NetworkFetcher *fetcher = [[NetworkFetcher alloc] initwithurl:url];
	fetcher.delegate = self;[fetcher start];
}
// ...

- (void)networkFetcher:(NetworkFetcher*)networkFetcher didFinishWithData:(NSData*)data {
	fetchedFooData = data;
}

这种做法确实可行,而且没有什么错误。然而如果改用块来写的话,代码会更清晰。块可以令这种API变得更紧致,同时也令开发者调用起来更加方便。办法就是:把 completion handler定义为块类型,将其当作参数直接传给start方法:

#import <Foundation/Foundation.h>
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data)
@interface NetworkFetcher :NSObject
(id)initWithURL:(NSURL*)url;
-(void)startWithCompletionHandler:(NetworkFetcherCompletionHandler)handler;
@end

这和使用委托协议很像,不过多了个好处,就是可以在调用start方法时直接以内联形式定义completion handler,以此方式来使用“网络数据获取器”(network fetcher),可以令代比原先易懂很多。例如,下面这个类就以块的形式来定义completion handler,并以此为参娄调用API:

- (void)fetchFooData {
	NSURL *url = [[NSURL alloc] initwithstring:@"..."];
	NetworkFetcher *fetcher = [[NetworkFetcher alloc] initWithURL;url];
	[fetcher startWithCompletionHandler:^(NSData *data) {
		_fetchedFooData = data;
	}];
}

与使用委托模式的代码相比,用块写出来的代码显然更为整洁。异步任务执行完毕后所运行的业务逻辑,和启动异步任务所用的代码放在了一起。而且,由于块声明在创建获取据的范围里,所以它可以访问此范围内的全部变量。本例比较简单,体现不出这一点,然而在更为复杂的场景中,会大有裨益。
委托模式有个缺点:如果类要分别使用多个获取器下载不同数据,那么就得在delegate回调方法里根据传人的获取器参数来切换。这种代码的写法如下:

- (void)fetchFooData {
	NSURL *url = [[NSURLalloc] initWithString:@"..."];
	fooFetcher = [[NetworkFetcher alloc] initWithUrl:url];
	fooFetcher.delegate = self;
	[_fooFetcher start];
}

- (void)fetchBarData {
	NSURL *url = [[NSURLalloc] initwithstring:@"..."];
	barFetcher = [[NetworkFetcher alloc] initWithURL:url];
	_barFetcher.delegate = self;
	[barFetcher start];
}
- (void)networkFetcher:(NetworkFetcher*)networkFetcher didFinishWithData:(NSData*)data {
	if (networkFetcher == _fooFetcher) {
		fetchedFooData = data;
		fooFetcher = nil;
	} else if (networkFetcher == _barFetcher) {
		fetchedBarData = data;
		barFetcher = nil;
	}
	// etc.
}

这么写代码,不仅会令delegate 回调方法变得很长,而且还要把网络数据获取器对象保存为实例变量,以便在判断语句中使用。这么做可能有其他原因,比如稍后要根据情况解除监听等,然而这种写法有副作用,通常很快就会使类的代码激增。改用块来写的好处是:无须保存获取器,也无须在回调方法里切换。每个completion handler的业务逻辑,都是和相关的获取器对象一起来定义的:

- (void)fetchFooData {
	NSURL *url = [[NSURL alloc] initWithString:@"..."];
	NetworkFetcher *fetcher = [[NetworkFetcher alloc] initwithURL:url];
	[fetcher startWithCompletionHandler:^(NSData *data) {
		_fetchedFooData = data;
	}];
}
- (void)fetchBarData {
	NSURL *url = [[NSURL alloc] initwithstring:@"..."];
	NetworkFetcher *fetcher = [[NetworkFetcher alloc] initwithUrL:url];
	[fetcher startWithCompletionHandler:^(NSData *data) {
		fetchedBarData =data;
	}];
}

这种写法还有其他用途,比如,现在很多基于块的API都使用块来处理错误。这又分为两种办法。可以分别用两个处理程序来处理操作失败的情况和操作成功的情况。也可以把处理失败情况所需的代码,与处理正常情况所用的代码,都封装到同一个completion handler里。如果想采用两个独立的处理程序,那么可以这样设计PI:

#import <Foundation/Foundation.h>

@class NetworkFetcher;

typedef void(^NetworkFetcherCompletionHandler)(NSData *data);
typedef void(^NetworkFetcherErrorHandler)(NSError *error);

@interface NetworkFetcher : NSObject

- (id)initwithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(NetworkFetcherCompletionHandler)completion failureHandler:(NetworkFetcherErrorHandler)failure;

@end

依照此风格设计出来的API,其调用方式如下:

NetworkFetcher *fetcher = [[NetworkFetcher alloc] initWithUrl:url];
[fetcher startWithCompletionHander:^(NSData *data) {
	//Handle success
} failureHandler:^(NSError *error) {
	//Handle failure
}];

这种设计很好于功的情况分别处理,所以调用此API的代码也就会按照逻辑,把应对成功和失败情况的代码分开来写,这将令代码更易读懂。而且,若有需要,还可以把处理失败情况或成功情况所用的代码省略
另一种风格则是像下面这样,把处理成功情况和失败情况所用的代码全放在一个块里:

#import <Foundation/Foundation.h>

@class NetworkFetcher;
typedef void(^NetworkFetcherCompletionHandler)(NSData *data, NSError *error);

@interface NetworkFetcher : NSObject
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:
(NetworkFetcherCompletionHandler)completion;
dend

此种API的调用方式如下

NetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initwithURL:url];
[fetcher startWithCompletionHander:^(NSData *data, NSError *error) {
	if (error) {
		// Handle failur
	} else {
		//Handle success
	}
}];

这种方式需要在块代码中检测传人的error变量,并且要把所有逻辑代码都放在一处。这种写法的缺点是:由于全部逻辑都写在一起,所以会令块变得比较长,且比较复杂。然而只用一个块的写法也有好处,那就是更为灵活。比方说,在传人错误信息时,可以把数据也传进来。有时数据正下载到一半,突然网络故障了。在这种情况下,可以把数据及相关的错误都回传给块。这样的话,completion handler就能据此判断问题并适当处理了,而且还可利用已下载好的这部分数据做些事情。
把成功情况和失败情况放在同一个块中,还有个优点:调用API的代码可能会在处理成功响应的过程中发现错误。比方说,返回的数据可能太短了。这种情况需要和网络数据获取器所认定的失败情况按同一方式处理。此时,如果采用单一块的写法,那么就能把这种情况和获取器所认定的失败情况统一处理了。要是把成功情况和失败情况交给两个不同的处理程序来负责,那么就没办法共享同一份错误处理代码了,除非把这段代码单独放在一个方法里,而这又违背了我们想把全部逻辑代码都放在一处的初衷。
总体来说,建议使用同一个块来处理成功与失败情况,苹果公司似乎也是这样设计其API的。例如,Twitter 框架中的 TWRequest 及 MapKit 框架中的 MKLocalSearch 都只使用一个handler 块。
有时需要在相关时间点执行回调操作,这种情况也可以使用handler块。比方说,调用网络数据获取器的代码,也许想在每次有下载进度时都得到通知。这可以通过委托模式实现。不过也可以使用本节讲的handler块,把处理下载进度的handler定义成块类型,并新增一个此类型的属性:

typedef void(^NetworkFetcherCompletionHandler)(float progress);
@property (nonatomic, copy) NetworkFetcherProgressHandler progressHandler;

这种写法很好,因为它还是能把所有业务逻辑都放在一起:也就是把创建网络数据获取器和定义progress handler所用的代码写在一处。
基于handler来设计API还有个原因,就是某些代码必须运行在特定的线程上。比方说,Cocoa与Cocoa Touch中的UI操作必须在主线程上执行。这就相当于GCD中的“主队列”(main queue)。因此,最好能由调用API的人来决定handler 应该运行在哪个线程上。 NSNotificationCenter就属于这种API,它提供了一个方法,调用者可以经由此方法来注册想要接收的通知,等到相关事件发生时,通知中心就会执行注册好的那个块。调用者可以指定某个块应该安排在哪个执行队列里,然而这不是必需的。若没有指定队列,则按默认方式执行,也就是说,将由投递通知的那个线程来执行。下列方法可用来新增观察者(observer):

- (id)addObserverForName:(NSString*)name object:(id)object queue:(NSOperationQueue*)queue usingBlock:(void(^)(NSNotification*))block

此处传人的NSOperationQueue参数就表示触发通知时用来执行块代码的那个队列。这是个“操作队列”(operation queue),而非“底层GCD队列”(low-level GCD queue),不过两者语义相同。
也可以照此设计自己的API,根据API所处的细节层次,可选用操作队列甚至GCD队列作为参数。

四、用块引用其所属对象时不要出现保留环

使用块时,若不仔细思量,则很容易导致“保留环”(retaincycle)。比如,下面这个类就提供了一套接口,调用者可由此从某个URL中下载数据。在启动获取器时,可设置completion handler,这个块会在下载结束之后以回调方式执行。为了能在下载完成后通过p_requestCompleted 方法执行调用者所指定的块,这段代码需要把 completionhandler 保存到实例变量里面。

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

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

@interface NetworkFetcher : NSObject

@property (nonatomic, strong, readonly) NSURL *url;
- (id)initWithURL:(NSURL*)url;
- (void)startWithCompletionHandler:(NetworkFetcherCompletionHandler)completion;

@end

// NetworkFetcher.m
#import"NetworkFetcher.h"

@interface NetworkFetcher ()
@property (nonatomic, strong, readwrite) NSURL *url;
@property (nonatomic,copy) NetworkFetcherCompletionHandler completionHandler;
@property (nonatomic, strong) NSData *downloadedData;
@end

@implementation NetworkFetcher
- (id)initWithURL:(NSURL*)url {
	if (self = [super init]) {
		_url = url;
	}
	return self;
}
- (void)startWithCompletionHandler:(NetworkFetcherCompletionHandler)completion {
	self.completionHandler = completion;
	//Start the request
	//Request sets downloadedData property
	//When reguest is finished, p_requestCompleted is called
}
- (void)p_requestCompleted {
	if(_completionHandler) {
		_completionHandler(_downloadedData);
	}
}

@end

某个类可能会创建这种网络数据获取器对象,并用其从URL中下载数据:

@implementation MyClass {
	NetworkFetcher *_networkFetcher;
	NSData *_fetchedData;
}

- (void)downloadData {
	NSURL *url = [[NSURL alloc] initWithstring:@"..."];
	_networkFetcher = [[NetworkFetcher alloc] initWithUrl:url];
	[_networkFetcher startWithCompletionHandler:^(NSData *data) {
		NSLog(@"Request URL %@ finished", _networkFetcher.url);
		_fetchedData = data;
	}];
}

@end

这段代码看上去没什么问题。但你可能没发现其中有个保留环。因为completion handler块要设置fetchedData实例变量,所以它必须捕获self变量。这就是说,handler块保留了创建网络数据获取器的那个MyClass实例。而MyClass实例则通过strong实例变量保留了获取器,最后,获取器对象又保留了handler块。
在这里插入图片描述

要打破保留环也很容易:要么令_networkFetcher实例变量不再引用获取器,要么令获取器的completionHandler属性不再持有handler块。在网络数据获取器这个例子中,应该等completion handler块执行完毕后,再去打破保留环,以便使获取器对象在handler块执行期间保持存活状态。比方说,completion handler块的代码可以这么修改:

[_networkFetcher startWithCompletionHandler:^(NSData *data) {
	NSLog(@"Request for URL &@ finished", _networkFetcher.url);
	fetchedData = data;
	networkFetcher = nil;
}];

如果设计API时用到了completionhandler这样的回调块,那么很容易形成保留环,所以必须意识到这个重要问题。一般来说,只要适时清理掉环中的某个引用,即可解决此题,然而,未必总有这种机会。在本例中,唯有completion handler运行过后,方能解除保留环。若是completion handler一直不运行,那么保留环就无法打破,于是内存就会泄漏。
completion handler块这种写法,还可能引人另外一种形式的保留环。如果completio handler块所引用的对象最终又引用了这个块本身,那么就会出现保留环。比方说,我们改一下前面那个例子,使调用API的那段代码无须在执行期间保留指向网络数据获取器的用,而是设定一套机制,令获取器对象自己设法保持存活。要想保持存活,获取器对象可在启动任务时把自己加到全局的collection中(比如用set来实现这个collection),待任务完成后,再移除。而调用方则需将其代码修改如下:

- (void)downloadData {
	NSURL *url = [[NSURL alloc] initWithString:@"..."];
	NetworkFetcher *networkFetcher = [[NetworkFetcher alloc] initWithURL:url];
	[networkFetcher startWithCompletionHandler:^(NSData *data) {
		NSLog(@"Request URL %@ finished", _networkFetcher.url);
		_fetchedData = data;
	}];
}

大部分网络通信库都采用这种办法,因为假如令调用者自己来将获取器对象保持存话的话,他们会觉得麻烦。Twitter框架的TWRequest对象也用这个办法。然而,就NetworkFetcher的现有代码来看,此做法会引入保留环。而这次比刚才那个例子更难于发觉,completion handler块其实要通过获取器对象来引用其中的URL。于是,块就要保留获取器,而获取器反过来又经由其completionHandler属性保留了这个块。所幸要修复这个问题也难。回想一下,获取器对象之所以要把completion handler块保存在属性里面,其唯一目的是想稍后使用这个块。可是,获取器一旦运行过 completion handler之后,就没有必要再保留它了。所以,只需将 p_requestCompleted方法按如下方式修改即可:

- (void)p_requestCompleted {
	if (_completionHandler) {
		_completionHandler(_downloadedData);
	}
	self.completionHandler = nil;
}

这样一来,只要下载请求执行完毕,保留环就解除了,而获取器对象也将会在必要时为系统所回收。请注意,之所以要在start方法中把 completion handler作为参数传进去,这也是一条重要原因。假如把completion handler暴露为获取器对象的公共属性,那么就不便在执行完下载请求之后直接将其清理掉了,因为既然已经把 handler作为属性公布了,那就意味着调用者可以自由使用它,若是此时又在内部将其清理掉的话,则会破坏“封装语义”(encapsulation semantic)。在这种情况下要想打破保留环,只有一个办法可用,那就是强迫调用者在handler代码里自己把completionHandler属性清理干净。可这并不是十分合理,因为你无法假定调用者一定会这么做,他们反过来会抱怨你没把内存泄漏问题处理好。
这两种保留环都很容易发生。使用块来编程时,一不小心就会出现这种bug,反过来说,只要小心谨慎,这种问题也很容易解决。关键在于,要想清楚块可能会捕获并保留哪些对象。如果这些对象又直接或间接保留了块,那么就要考虑怎样在适当的时机解除保留环

五、多用派发队列,少用同步锁

在OC 中,如果有多个线程要执行同一份代码,那么有时可能会出问题。这种情况下,通常要使用锁来实现某种同步机制。在GCD出现之前,有两种办法,第一种是采用内置的“同步块”(synchronization block):

- (void)synchronizedMethod {
	@synchronized(self) {
		// Safe
	}
}

这种写法会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。在本例中,同步行为所针对的对象是self。这么写通常没错,因为它可以保证每个对象实例都能不受干扰地运行其synchronizedMethod方法。然而,滥用@synchronized(self)则会降低代码效率,因为共用同一个锁的那些同步块,都必须按顺序执行。若是在self对象上频繁加锁,那么程序可能要等另一段与此无关的代码执行完毕,才能继续执行当前代码,这样做其实并没有必要。
另一个办法是直接使用NSLock对象:

lock = [[NSLock alloc] init];
- (void)synchronizedMethod {
	[_lock lock];// Safe
	[_lock unlock];
}

也可以使用NSRccursiveLock这种“递归锁”(重入锁,recursive lock),线程能够多次持有该锁而不会出现死锁(deadlock)现象。
这两种方法都很好,不过也有其缺陷。比方说,在极端情况下,同步块会导致死锁,另外,其效率也不见得很高,而如果直接使用锁对象的话,一旦遇到死锁,就会非常麻烦。
替代方案就是使用GCD,它能以更简单、更高效的形式为代码加锁。比方说,属性就是开发者经常需要同步的地方,这种属性需要做成“原子的”。用atomic特质来修饰属性,即可实现这一点。而开发者如果想自己来编写访问方法的话,那么通常会这样写

- (NSString*)someString {
	@synchronized(self) {
		return _someString;
	}
}

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

刚才说过,滥用@synchronized(self)会很危险,因为所有同步块都会彼此抢夺同一个锁。要是有很多个属性都这么写的话,那么每个属性的同步块都要等其他所有同步块执行完毕才能执行,这也许并不是开发者想要的效果。我们只是想令每个属性各自独立地同步。
顺便说一下,这么做虽然能提供某种程度的“线程安全”(thread safety),但却无法保证访问该对象时绝对是线程安全的。当然,访问属性的操作确实是“原子的”。使用属性时必定能从中获取到有效值,然而在同一个线程上多次调用获取方法(getter),每次获取到的结果却未必相同。在两次访问操作之间,其他线程可能会写人新的属性值。
有种简单而高效的办法可以代替同步块或锁对象,那就是使用“串行同步队列”(serial synchronization queue)。将读取操作及写人操作都安排在同一个队列里,即可保证数据同步。其用法如下:

_syncQueue =
dispatch_queue_create("com.mine.syncQueue", NULL);
- (NSString*)someString {
	__block NSString *localSomeString;
	dispatch_sync(_syncQueue, ^{
		localSomeString = _someString;
	});
	return localsomeString;
}
- (void)setSomeString:(NSString*)someString {
	dispatch_sync(_syncQueue, ^{
		_someString = someString;
	});
}

此模式的思路是:把设置操作与获取操作都安排在序列化的队列里执行,这样的话,所有针对属性的访问操作就都同步了。为了使块代码能够设置局部变量,获取方法中用到了__block 语法,若是抛开这一点,那么这种写法要比前面那些更为整洁。全部加锁任务都在 GCD 中处理,而 GCD 是在相当深的底层来实现的,于是能够做许多优化。因此,开发者无须担心那些事,只要专心把访问方法写好就行。
然而还可以进一步优化。设置方法并不一定非得是同步的。设置实例变量所用的块,并不需要向设置方法返回什么值。也就是说,设置方法的代码可以改成下面这样:

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

这次只是把同步派发改成了异步派发,从调用者的角度来看,这个小改动可以提升设置方法的执行速度,而读取操作与写入操作依然会按顺序执行。但这么改有个坏处:如果测一下程序性能,那么可能会发现这种写法比原来慢,因为执行异步派发时,需要拷贝块。若拷贝块所用的时间明显超过执行块所花的时间,则这种做法将比原来更慢。由于本书所举的这个例子很简单,所以改完之后很可能会变慢。然而,若是派发给队列的块要执行更为繁重的任务,那么仍然可以考虑这种备选方案。
多个获取方法可以并发执行,而获取方法与设置方法之间不能并发执行,利用这个特点,还能写出更快一些的代码来。此时正可以体现出GCD写法的好处。用同步块或锁对象,是无法轻易实现出下面这种方案的。这次不用串行队列,而改用并发队列(concurrent queue):

_syncQueue = 
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, O);

- (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);

在队列中,栅栏块必须单独执行,不能与其他块并行。这只对并发队列有意义,因为串行队列中的块总是按顺序逐个来执行的。并发队列如果发现接下来要处理的块是个栅栏块(barrier block),那么就一直要等当前所有并发块都执行完毕,才会单独执行这个栅栏块。待栅栏块执行过后,再按正常方式继续向下处理。
在这里插入图片描述

在本例中,可以用栅栏块来实现属性的设置方法。在设置方法中使用了栅栏块之后,对属性的读取操作依然可以并发执行,但是写人操作却必须单独执行了。在演示的这个队列中,有许多读取操作,而且还有一个写入操作。
实现代码很简单:

_syncQueue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, O);

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

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

测试一下性能,就会发现,这种做法肯定比使用串行队列要快。注意,设置函数也可以改用同步的栅栏块(synchronous barrier)来实现,那样做可能会更高效,其原因刚才已经解释过了。最好还是测一测每种做法的性能,然后从中选出最适合当前场景的方案。

要点

  • 派发队列可用来表述同步语义(synchronization semantic),这种做法要比使用@synchronized 块或 NSLock 对象更简单。
  • 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的线程。
  • 使用同步队列及栅栏块,可以令同步行为更加高效。

六、多用GCD,少用performSelector系列方法

OC本质上是一门非常动态的语言,NSObject定义了几个方法,令开发者可以随意调用任何方法。这几个方法可以推迟执行方法调用,也可以指定运行方法所用的线程。这些功能原来很有用,但是在出现了大中枢派发及块这样的新技术之后,就显得不那么必要了。虽说有些代码还是会经常用到它们,但还是避开为妙。
这其中最简单的是“performSelector:”。该方法的签名如下,它接受一个参数,就是要执行的那个选择子:

- (id)performSelector:(SEL)selector

该方法与直接调用选择子等效。所以下面两行代码的执行效果相同:

[object performSelector:@selector(selectorName)];
[object selectorName];

这种方式看上去似乎多余。如果某个方法只是这么来调用的话,那么此方式确实多余。然而,如果选择子是在运行期决定的,那么就能体现出此方式的强大之处了。这就等于在动态绑定之上再次使用动态绑定,因而可以实现出下面这种功能:

SEL selector;
if ( /* some condition */ ) {
	selector = @selector(foo);
} else if ( /* some other condition */ ) {
	selector = @selector(bar);
} else {
	selector = @selector(baz);
}
[obiect performSelector:selector];

种编程方式极为灵活,经常可用来简化复杂的代码。还有一种用法,就是先把选择子存起来,等某个事件发生之后再调用。不管哪种用法,编译器都不知道要执行的选择子是什么,这必须到了运行期才能确定。然而,使用此特性的代价是,如果在ARC下编译代码。那么编译器会发出如下警示信息

warning: performSelector may cause a leak because its selector is unknown [-Warc-performSelector-leaks]

你可能没料到会出现这种警告。要是早就料到了,那么你也许已经知道使用这些方法时为何要小心了。这条消息看上去可能比较奇怪,而且令人纳闷:为什么其中会提到内存泄漏问题呢?只不过是用“performSelector:”调用了一个方法。原因在于,编译器并不知道将要调用的选择子是什么,因此,也就不了解其方法签名及返回值,甚至连是否有返回值都不清楚。而且,由于编译器不知道方法名,所以就没办法运用ARC 的内存管理规则来判定返回值是不是应该释放。鉴于此,ARC 采用了比较谨慎的做法,就是不添加释放操作。然而这么做可能导致内存泄漏,因为方法在返回对象时可能已经将其保留了。
考虑下面这段代码:

SEL selector;
if ( /* some condition */ ) {
	selector = @selector(newObject);
} else if ( /* some other condition */ ) {
	selector = @selector(copy);
} else {
	selector = @selector(someProperty);
}
id ret = lobject performSelector:selector];

此代码与刚才那个例子稍有不同,以便展示问题所在。如果调用的是两个选择子之一,那么ret 对象应由这段代码来释放,而如果是第三个选择子,则无须释放。不仅在ARC环境下应该如此,而且在非ARC环境下也应该这么做,这样才算严格遵循了方法的命名规范。如果不使用ARC(此时编译器也就不发警告信息了),那么在前两种情况下需要手动释放ret对象,而在后一种情况下则不需要释放。这个问题很容易忽视,而且就算用静态分析器,也很难侦测到随后的内存泄漏。performSelector 系列的方法之所以要谨慎使用,这就是其中一个原因。
这些方法不甚理想,另一个原因在于:返回值只能是void 或对象类型。尽管所要执行的选择子也可以返回 void,但是performSelector 方法的返回值类型毕竟是id。如果想返回整数或浮点数等类型的值,那么就需要执行一些复杂的转换操作了,而这种转换很容易出错。由于 id 类型表示指向任意OC对象的指针,所以从技术上来讲,只要返回值的大小和指针所占大小相同就行,也就是说:在32 位架构的计算机上,可以返回任意 32 位大小的类型;而在64位架构的计算机上,则可返回任意 64 位大小的类型。若返回值的类型为C语言结构体,则不可使用performSelector 方法。
performSelector 还有如下几个版本,可以在发消息时顺便传递参数:

- (id)performSelector:(SEL)selector withObject:(id)object
-(id)performSelector:(SEL)selector withobject:(id)objectA withobject:(id)objectB

比方说,可以用下面这个版本来设置对象中名为 value 的属性值:

id object = /* an object with a property called value */;
id newvalue = /* new value for the property */;
[object performSelector:@selector(setValue:) withobject:newValue];

这些方法貌似有用,但其实局限颇多。由于参数类型是id,所以传入的参数必须是对象才行。如果选择子所接受的参数是整数或浮点数,那就不能采用这些方法了。此外,选择子最多只能接受两个参数,也就是调用“performSelector:withObject:withObject:”这个版本。而在参数不止两个的情况下,则没有对应的performSelector方法能够执行此种选择子。
performSelector 系列方法还有个功能,就是可以延后执行选择子,或将其放在另一个线程上执行。下面列出了此方法中一些更为常用的版本:

- (void)performSelector:(SEL)selector withobject:(id)argument afterDelay:(NSTimeInterval)delay
- (void)performSelector:(SEL)selector onThread:(NSThread*)thread withObject:(id)argument waitUntilDone:(BOOL)wait
- (void)performSelectorOnMainThread:(SEL)selector withObject:(id)argument waitUntildone:(BOOL)wait

然而很快就会发觉,这些方法太过局限了。例如,具备延后执行功能的那些方法都无法处理带有两个参数的选择子。而能够指定执行线程的那些方法,则与之类似,所以也不是特别通用。如果要用这些方法,就得把许多参数都打包到字典中,然后在受调用的方法里将其提取出来,这样会增加开销,而且还可能出 bug
如果改用其他替代方案,那就不受这些限制了。最主要的替代方案就是使用块。而且,performSelector 系列方法所提供的线程功能,都可以通过在大中枢派发机制中使用块来实现。延后执行可以用 dispatch_after 来实现,在另一个线程上执行任务则可通过 dispatch_syncdispatch_async 来实现。
例如,要延后执行某项任务,可以有下面两种实现方式,而我们应该优先考虑第二种:

//Using performSelector:withobject:afterDelay:[self 
performSelector:@selector(dosomething) withobject:nil afterDelay:5.0];

//Using dispatch_after
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];
});

想把任务放在主线程上执行,也可以有下面两种方式,而我们还是应该优选后者:

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

//Using dispatch_async
//(or if waitUntildone is YES, then dispatch_sync) 
dispatch_async(dispatch_get_main_queue()^{
	[self doSomething];
});

七、掌握GCD及操作队列的使用时机

GCD技术确实很棒,不过有时候采用标准系统库的组件效果会更好。一定要了解每项技巧的使用时机,如果选错了工具,那么编出来的代码就会难于维护。
很少有其他技术能与GCD的同步机制相媲美。对于那些只需执行一次的代码来说,也是如此,使用GCD的dispatch_once最为方便。然而,在执行后台任务时,GCD并不一定是最佳方式。还有一种技术叫做NSOperationQueue,它然与GCD不同,但是却与之相关,开发者可以把操作以NSOperation子类的形式放在队列中,而这些操作也能够并发执行。其与GCD派发队列有相似之处,这并非巧合。“操作队列"(operation queue)在GCD之前就有了,其中某些设计原理因操作队列而流行,GCD 就是基于这些原理构建的。实际上,从iOS4与Mac OS X 10.6开始,操作队列在底层是用GCD来实现的。
在两者的诸多差别中,首先要注意:GCD是纯C的API,而操作队列则是OC的对象。在GCD中,任务用块来表示,而块是个轻量级数据结构。与之相反,“操作”(operation)则是个更为重量级的OC 对象。虽说如此,但GCD 并不总是最佳方案。有时候采用对象所带来的开销微乎其微,使用完整对象所带来的好处反而大大超过其缺点。
用NSOperationQueue类的“addOperationWithBlock:”方法搭配 NSBlockOperation 类来使用操作队列,其语法与纯GCD 方式非常类似。使用NSOperation及NSOperationQueue 的好处如下:

  • 取消某个操作。如果使用操作队列,那么想要取消操作是很容易的。运行任务之前可以在NSOperation对象上调用cancel方法,该方法会设置对象内的标志位,用以表明此任务不需执行,不过,已经启动的任务无法取消。若是不使用操作队列,而是把块安排到GCD队列,那就无法取消了。那套架构是“安排好任务之后就不管了”(fire and forget)。开发者可以在应用程序层自己来实现取消功能,不过这样做需要编写很多代码,而那些代码其实已经由操作队列实现好了。
  • 指定操作间的依赖关系。一个操作可以依赖其他多个操作。开发者能够指定操作之间的依赖体系,使特定的操作必须在另外一个操作顺利执行完毕后方可执行。比方说从服务器端下载并处理文件的动作,可以用操作来表示,而在处理其他文件之前,必须先下载“清单文件”(manifest file)。后续的下载操作,都要依赖于先下载清单文件这一操作。如果操作队列允许并发的话。那么后续的多个下载操作就可以同时执行但前提是它们所依赖的那个清单文件下载操作已经执行完毕。
  • 通过键值观测机制监控NSOperation对象的属性。NSOperation对象有许多属性都话合通过键值观测机制(简称KVO)来监听,比如可以通过isCancelled属性来判断任务是否已取消,又比如可以通过isFinished属性来判断任务是否已完成。如果想在某个任务变更其状态时得到通知,或是想用比GCD更为精细的方式来控制所要执行的任务,那么键值观测机制会很有用。
  • 指定操作的优先级。操作的优先级表示此操作与队列中其他操作之间的优先关系。优先级高的操作先执行,优先级低的后执行。操作队列的调度算法(schedulins algorithm)虽“不透明”(opaque),但必然是经过一番深思熟虑才写成的。反之,GCD则没有直接实现此功能的办法。GCD的队列确实有优先级,不过那是针对整个队列来说的,而不是针对每个块来说的。而令开发者在GCD之上自己来编写调度算法又不太合适。因此,在优先级这一点上,操作队列所提供的功能要比GCD更为便利 NSOperation对象也有“线程优先级”(thread priority),这决定了运行此操作的线程处在何种优先级上。用GCD也可以实现此功能,然而采用操作队列更简单,只需设置一个属性
    重用NSOperation对象。系统内置了一些NSOperation的子类(比如NSBlockOperation)供开发者调用,要是不想用这些固有子类的话,那就得自己来创建了。这些类就是普通的OC对象,能够存放任何信息。对象在执行时可以充分利用存于其中的信息,而且还可以随意调用定义在类中的方法。这就比派发队列中那些简单的块要强大许多。这些NSOperation类可以在代码中多次使用,它们符合软件开发中的“不重复(Don’t Repeat Yourself,DRY)原则。

正如大家所见,操作队列有很多地方胜过派发队列。操作队列提供了多种执行任务的方式,而且都是写好了的,直接就能使用。开发者不用再编写复杂的调度器,也不用自己来实现取消操作或指定操作优先级的功能,这些事情操作队列都已经实现好了。
有一个API选用了操作队列而非派发队列,这就是NSNotificationCenter,开发者可通过其中的方法来注册监听器,以便在发生相关事件时得到通知,而这个方法接受的参数是块不是选择子。方法原型如下:

-(id)addobserverForName:(NSString*)name object:(id)object queue:(NSOperationQueue*)queue usingBlock:(void(^)(NSNotification*))block

本来这个方法也可以不使用操作队列,而是把处理通知事件所用的块安排在派发队列里。但实际上并没有这样做,其设计者显然使用了高层的OC API。在这种情况下两套方案的运行效率没多大差距。设计这个方法的人可能不想使用派发队列,因为那样做将依赖于GCD,而这种依赖没有必要,前面说过,块本身和GCD无关,所以如果仅使用块的话,就不会引人对GCD的依赖了。也有可能是编写这个方法的人想全部用OC来描述,而不想使用纯C的东西。
经常有人说:应该尽可能选用高层API,只在确有必要时才求助于底层,但并不应该盲从。某些功能确实可以用高层的OC方法来做,但这并不等于说它就一定比底层实现方案好。要想确定哪种方案更佳,最好还是测试一下性能。

八、通过Dispatch Group机制,根据系统资源状况来执行任务

dispatch group(“派发分组”或“调度组”)是GCD的一项特性,能够把任务分组。调用者可以等待这组任务执行完毕,也可以在提供回调函数之后继续往下执行,这组任务完成时,调用者会得到通知。这功能有许多用途,其中最重要、最值得注意的用法,就是把将要并发执行的多个任务合为-组,于是调用者就可以知道这些任务何时才能全部执行完毕。比方说,可以把压缩一系列文件的任务表示成dispatch group
下面这个函数可以创建dispatch group:

dispatch_group_t dispatch_group_create();

dispatch group就是个简单的数据结构,这种结构彼此之间没什么区别,它不像派发队列,后者还有个用来区别身份的标识符。想把任务编组,有两种办法。第一种是用下面这个函数:

void dispatch_group_async(dispatch group_t group,
						  dispatch_queue_t queue, 
						  dispatch_block_t block);

它是普通dispatch_async函数的变体,比原来多一个参数,用于表示待执行的块所归属的组。还有种办法能够指定任务所属的dispatch group,那就是使用下面这一对函数:

void dispatch_group_enter(dispatch_group_t group);
void dispatch_group_leave(dispatch_group_t group);

前者能够使分组里正要执行的任务数递增,而后者则使之递减。由此可知,调用了 dispatch_group_enter 以后,必须有与之对应的 dispatch_group_leave 才行。这与引用计数相似,要使用引用计数,就必须令保留操作与释放操作彼此对应,以防内存泄漏。而在使用dispatch group时,如果调用enter之后,没有相应的leave操作,那么这一组任务就永远执行不完。
下面这个函数可用于等待dispatch group执行完毕:

long dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);

此函数接受两个参数,一个是要等待的group,另一个是代表等待时间的timeout值。 timeout参数表示函数在等待dispatch group执行完毕时,应该阻塞多久。如果执行dispatch group所需的时间小于timeout,则返回0,否则返回非0值。此参数也可以取常量DISPATCH_TIME_FOREVER,这表示数会一直等着dispatch group执行完,而不会超时(time out)。
除了可以用上面那个函数等待dispatch group执行完毕之外,也可以换个办法,使用下列函数:

void dispatch_group_notify(dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block);

wait 函数略有不同的是:开发者可以向此函数传入块,等dispatch group 执行完毕之后,块会在特定的线程上执行。假如当前线程不应阻塞,而开发者又想在那些任务全部完成时得到通知,那么此做法就很有必要了。比方说,在Mac OS X与iOS 系统中,都不应阻塞主线程,因为所有UI绘制及事件处理都要在主线程上执行。
如果想令数组中的每个对象都执行某项任务,并且想等待所有任务执行完毕,那么就可以使用这个GCD特性来实现。代码如下:

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, O);
dispatch_group_t dispatchGroup = dispatch_group_create();
for (id object in collection) {
	dispatch_group_async(dispatchGroup queue, ^{
		[object performTask];
	});
}
dispatch_group_wait(dispatchGroup, DISPATCH TIME FOREVER);
// Continue processing after completing tasks

若当前线程不应阻塞,则可用notify函数来取代wait

dispatch_queue_t notifyQueue = dispatch_get_main_queue(); dispatch_group_notify(dispatchGroup, notifyQueue, ^{
	//Continue processing after completing tasks
});

notify回调时所选用的队列,完全应该根据具体情况来定。笔者在范例代码中使用了主队列,这是种常见写法。也可以用自定义的串行队列或全局并发队列。
在本例中,所有任务都派发到同一个队列之中。但实际上未必一定要这样做。也可以把某些任务放在优先级高的线程上执行,同时仍然把所有任务都归入同一个dispatch group,并在执行完毕时获得通知:

dispatch_queue_t lowPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, O);
dispatch_queue_t highPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, O);
dispatch_group_t dispatchGroup = dispatch_group_create();
for (id object in lowPriorityobjects) {
	dispatch_group_async(dispatchGroup, lowPriorityQueue, ^{
		[object performTask]; 
	});
}
for (id object in highPriorityobjects) {
	dispatch_group_async(dispatchGroup, highPriorityQueue, ^{
		[object performTask]; 
	});
}
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup, notifyQueue, ^{
	//Continue processing after completing tasks
}) ;

除了像上面这样把任务提交到并发队列之外,也可以把任务提交至各个串行队列中,并用 dispatch group跟踪其执行状况。然而,如果所有任务都排在同一个串行队列里面,那么dispatch group就用处不大了。因为此时任务总要逐个执行,所以只需在提交完全部任务之后再提交一个块即可,这样做与通过notify函数等待dispatch group 执行完毕然后再回调块是等效的:

dispatch_queue_t queue = dispatch_queue_create("com.mine.queue", NULL);
for (id object in collection) {
	dispatch_async(queue, ^{
		[object performTask];
	});
}
dispatch_async(queue, ^{
	// Continue processing after completing tasks
});

上面这段代码表明,开发者未必总需要使用dispatch group。有时候采用单个队列搭配标准的异步派发,也可实现同样效果。
笔者为何要在标题中谈到“根据系统资源状况来执行任务”呢?回头看看向并发队列发任务的那个例子,就会明白了。为了执行队列中的块,GCD会在适当的时机自动创建新线程或复用旧线程。如果使用并发队列,那么其中有可能会有多个线程,这也就意味着多个可以并发执行。在并发队列中,执行任务所用的并发线程数量,取决于各种因素,而GCD主要是根据系统资源状况来判定这些因素的。假如CPU有多个核心,并且队列中有大量任务等待执行,那么GCD就可能会给该队列配备多个线程。通过dispatch group所提供的这简便方式,既可以并发执行一系列给定的任务,又能在全部任务结束时得到通知。由于GCD有并发队列机制,所以能够根据可用的系统资源状况来并发执行任务。而开发者则可以专于业务逻辑代码,无须再为了处理并发任务而编写复杂的调度器。
在前面的范例代码中,我们遍历某个collection,并在其每个元素上执行任务,而这也可以用另外一个GCD数来实现:

void dispatch apply(size_t iterations, dispatch_queue_t queue, void(^block)(size_t));

此函数会将块反复执行一定的次数,每次传给块的参数值都会递增,从0开始,直至"iterations-1”。其用法如下:

dispatch_queue_t queue = dispatch_queue_create("com.mine.queue", NULL);
dispatch_apply(10, queue, ^(size_t i) {
	// Perform task
});

采用简单的for循环,从0递增至9,也能实现同样效果:

for (int i = 0; i < 10; i++) {
	// Perform task
}

有一件事要注意:dispatch_apply所用的队列可以是并发队列。如果采用并发队列,那么系统就可以根据资源状况来并行执行这些块了,这与使用dispatch group的那段范例代码一样。上面这个 for 循环要处理的collection 若是数组,则可用dispatch_apply 改写如下:

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

这个例子再次表明:未必总要使用dispatch group。然而,dispatch_apply持续阻塞,直到所有任务都执行完毕为止。由此可见:假如把块派给了当前队列(或者体系中高于当前队列的某个串行队列),就将导致死锁。若想在后台执行任务,则应使用dispatch group

要点

  • 一系列任务可归入一个dispatch group之中。开发者可以在这组任务执行完毕时获得通知。
  • 通过 dispatch group,可以在并发式派发队列里同时执行多项任务。此时 GCD 会根据系统资源状况来调度这些并发执行的任务。开发者若自己来实现此功能,则需编写大量代码。

九、使用diapatch_once来执行只需运行一次的线程安全代码

单例模式(singleton)对OC开发者来说并不陌生,常见的实现方式为:在类中编写名为sharedInstance的方法,该方法只会返回全类共用的单例实例,而不会在每次调用时都创建新的实例。假设有个类叫做MyClass,那么这个共享实例的方法一般都会这样写:

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

单例模式容易引起激烈争论,OC的单例尤其如此。线程安全是大家争论的主要问题。为保证线程安全,上述代码将创建单例实例的代码包裹在同步块里。无论是好是坏,反正这种实现方式很常用,这样的代码随处可见。
不过,GCD引人了一项特性,能使单例实现起来更为容易。所用的函数是:

void dispatch_once(dispatch once_t *token, dispatch_block_t block);

此函数接受类型为dispatch_once_t的特殊参数,笔者称其为“标记”(token),此外还接受块参数。对于给定的标记来说,该函数保证相关的块必定会执行,且仅执行一次。首次调用该函数时,必然会执行块中的代码,最重要的一点在于,此操作完全是线程安全的。请注意,对于只需执行一次的块来说,每次调用函数时传入的标记都必须完全相同。因此,开发者通常将标记变量声明在staticglobal作用域里。
刚才实现单例模式所用的sharedInstance方法,可以用此函数来改写:

+ (id)sharedInstance {
	static MyClass *sharedInstance = nil;
	static dispatch_once_t onceToken;
	dispatch_once(&onceToken, ^{
		sharedInstance = [[self alloc] init];
	});
	return sharedinstance;
}

使用dispatch_once 可以简化代码并且彻底保证线程安全,开发者根本无须担心加锁或同步。所有问题都由GCD在底层处理。由于每次调用时都必须使用完全相同的标记,所以标记要声明成static。把该变量定义在static作用域中,可以保证编译器在每次执行 sharedInstance 方法时都会复用这个变量,而不会创建新变量。
比外,dispatch_once高效。它没有使用重量级的同步机制,若是那样做的话,每次运行代码前都要获取锁,相反,此函数采用“原子访问”(atomic access)来查询标记,以判断其所对应的代码原来是否已经执行过。在装有 64位 Mac OS X 10.8.2 系统的电脑上测试性能,分别采用@synchronized 方式及dispatch_once 方式来实现 sharedInstance方法,结果显示,后者的速度几乎是前者的两倍。

十、不要使用dispatch_get_current_queue

使用GCD时,经常需要判断当前代码正在哪个队列上执行,向多个队列派发任务时更是如此。例如,Mac OS X与iOS的UI事务都需要在主线程上执行,而这个线程就相当于 GCD 中的主队列。有时似乎需要判断出当前代码是不是在主队列上执行。阅读开发文档时,大家会发现下面这个函数:

dispatch_queue_t dispatch_get_current_queue()

文档中说,此函数返回当前正在执行代码的队列。确实是这样,不过用的时候要小心。实际上,iOS系统从6.0版本起,已经正式弃用此函数了。不过Mac OS X系统直到10.8版本也尚未将其废弃。虽说如此,但在 Mac OS X系统里还是要避免使用它。
该函数有种典型的错误用法(antipattern,“反模式”),就是用它检测当前队列是不是某个特定的队列,试图以此来避免执行同步派发时可能遭遇的死锁问题。考虑下面这两个存取方法,其代码用队列来保证对实例变量的访问操作是同步的:

- (NSString*)someString {
	__block NSString *localSomeString;
	dispatch_sync(_syncQueue, ^{
		localSomeString = _someString;
	});
	return localSomestring;
}
- (void)setSomeString:(NSString*)someString {
	dispatch_async(_syncQueue, ^{
		_someString = someString;
	});
}

这种写法的问题在于,获取方法(getter)可能会死锁,假如调用获取方法的队列恰好是同步操作所针对的队列(本例中是_syncQueue),那么dispatch_sync 就一直不会返回,直到块执行完毕为止。可是,应该执行块的那个目标队列却是当前队列,而当前队列的 dispatch sync 又一直阻塞着,它在等待目标队列把这个块执行完,这样一来,块就永远没机会执行了。像someString这种方法,就是“不可重入的”。

在实时系统的设计中,经常会出现多个任务调用同一个函数的情况。如果有一个函数不幸被设计成为这样:那么不同任务调用这个函数时可能修改其他任务调用这个函数的数据,从而导致不可预料的后果。这样的函数是不安全的函数,也叫不可重入函数
相反,肯定有一个安全的函数,这个安全的函数又叫可重入函数。那么什么是可重入函数呢?所谓可重入是指一个可以被多个任务调用的过程,任务在调用时不必担心数据是否会出错。

看了 dispatch_get_current_queue 的文档后,你也许觉得可以用它改写这个方法,令其变得“可重入”,只需检测当前队列是否为同步操作所针对的队列,如果是,就不派发了,直接执行块即可:

- (NSString*)someString {
	__block NSString *localSomeString;
	dispatch_block_t accessorBlock = ^{
		localSomeString=_someString;
	};
	if (dispatch_get_current_queue() == _syncQueue) {
		accessorBlock();
	} else {
		dispatch_sync(_syncQueue, accessorBlock);
	}
	return localSomestring;
}

这种做法可以处理一些简单情况。不过仍然有死锁的危险。为说明其原因,请读者考虑下面这段代码,其中有两个串行派发队列

dispatch_queue_t queueA = dispatch_queue_create("com.mine.queueA",NULL);
dispatch_queue_t queueB  = dispatch_queue_create("com.mine.queueB", NULL);
dispatch_sync(queueA, ^{
	dispatch_sync(queueB,^{
		dispatch_sync(queueA, ^{
			// Deadlock
		});
	});
});

这段代码执行到最内层的派发操作时,总会死锁,因为此操作是针对 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的同步派发操作依然会执行,于是和刚才一样,还是死锁了。
在这种情况下,正确做法是:不要把存取方法做成可重入的,而是应该确保同步操作所用的队列绝不会访问属性,也就是绝对不会调用someString 方法。这种队列只应该用来同步属性。由于派发队列是一种极为轻量的机制,所以,为了确保每项属性都有专用的同步队列,我们不妨创建多个队列。
刚才那个例子似乎稍显做作,但是使用队列时还要注意另外一个问题,而那个问题会在你意想不到的地方导致死锁。队列之间会形成一套层级体系,这意味着排在某条队列中的块,会在其上级队列(parent queue,也叫“父队列”)里执行。层级里地位最高的那个队列总是“全局并发队列”(global concurrent queue)。
在这里插入图片描述

排在队列B或队列C中的块,稍后会在队列里依序执行。于是,排在队列A、B、C中的块总是要彼此错开执行。然而,安排在队列D中的块,则有可能与队列A里的块(也包括队列B与C里的块)并行,因为A与D的目标队列是个并发队列。若有必要,并发队列可以用多个线程并行执行多个块,而是否会这样做,则需根据CPU的核心数量等系统资源状况来定。
由于队列间有层级关系,所以“检查当前队列是否为执行同步派发所用的队列”这种办法,并不总是奏效。比方说,排在队列C里的块,会认为当前队列就是队列C,而开发者可能会据此认定:在队列A上能够安全地执行同步派发操作。但实际上,这么做依然会像前面那样导致死锁。
有的API可令开发者指定运行回调块时所用的队列,但实际上却会把回调块安排在内部的串行同步队列上,而内部队列的目标队列又是开发者所提供的那个队列,在此情况下也许就要出现刚才说的那种问题了。使用这种API的开发者可能误以为:在回调块里调用 dispatch_get_current_qucue所返回的“当前队列”,总是其调用API时指定的那个。但实际上返回的却是API内部的那个同步队列
要解决这个问题,最好的办法就是通过GCD所提供的功能来设定“队列特有数据”(queue-specific data),此功能可以把任意数据以键值对的形式关联到队列里。最重要之处在于。假如根据指定的键获取不到关联数据,那么系统就会沿着层级体系向上查找,直至找到数据或到达根队列为止。这么说,也许还不太明白其用法,所以来看下面这个例子:

dispatch queue t queueA = dispatch_queue_create("com.mine.queueA", NULL);
dispatch_queue_t queueB = dispatch_queue_create("com.mine.queueB", NULL);
dispatch set_target_queue(queueB, queueA);
static int kQueueSpecific;
CFStringRef queueSpecifcValue = CFSTR("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);
	}
});

本例创建了两个队列。代码中将队列B的目标队列设为队列A,而队列A的目标队列仍然是默认优先级的全局并发队列。然后使用下列函数,在队列A上设置“队列特定值”:

void dispatch_queue_set_specific(dispatch_queue_t queue, const void *key, void *context, dispatch_function_t destructor);

此函数的首个参数表示待设置数据的队列,其后两个参数是键与值。键与值都是不透明的void指针。对于键来说,有个问题一定要注意:函数是按指针值来比较键的,而不是按照其内容。所以,“队列特定数据”的行为与NSDictionary对象不同,后者是比较键的“对象等同性”。“队列特定数据”更像是“关联引用”(associated reference)。值(在函数原型里叫做“context”)也是不透明的void指针,于是可以在其中存放任意数据。然而,必须管理该对象的内存。这使得在ARC环境下很难使用OC对象作为值。范例代码使用CoreFoundation字符串作为值,因为ARC并不会自动管理CoreFoundation 对象的内存。所以说,这种对象非常适合充当“队列特定数据”,它们可以根据需要与相关的 OC Foundation 类无缝衔接。
函数的最后一个参数是“析构函数”(destructor function),对于给定的键来说,当队列所占内存为系统所回收,或者有新的值与键相关联时,原有的值对象就会移除,而析构函数也会于此时运行。dispatch_functiont类型的定义如下:

typedef void (*dispatch_function_t)(void*)

由此可知,析构函数只能带有一个指针参数且返回值必须为void。范例代码采用 CFRelease做析构函数,此函数符合要求,不过也可以采用开发者自定义的函数,在其中调用CFRelease 以清理旧值,并完成其他必要的清理工作。
于是,“队列特定数据”所提供的这套简单易用的机制,就避免了使用dispatch_get current_queue时经常遭遇的一个陷阱。此外,调试程序时也许会经常用到dispatch_get current_queue。在此情况下,可以放心使用这个已经废弃的方法,只是别把它编译到发行版的程序里就行。如果对“访问当前队列”这项操作有特殊需求,而现有函数又无法满足,那么最好还是联系苹果公司,请求其加入此功能。

要点

  • dispatch_get_current_queue 函数的行为常常与开发者所预期的不同。此函数已经废弃只应做调试之用。
  • 由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列"这一概念。
  • dispatch_get_current_queue函数用于解决由不可重入的代码所引发的死锁,然而能用此函数解决的问题,通常也能改用“队列特定数据”来解决。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值