当前多线程编程的核心就是“块”(block)与“大中枢派发”(Grand Central Dispatch,GCD)。
37.理解“块”这一概念
1.块的基础知识
块与函数类似,只不过是直接定义在另一个函数里的,和定义它的那个函数共享同一个范围内的东西。块用“^”符号来表示,后面跟着一对花括号,括号里面是块的实现代码。
^{
//Block implementation
}
//块类型的语法结构及事例
return_type (^block_name)(parameters)
//使用
block_name(parameters);
eg:
int (^addBlock)(int a, int b) = ^(int a, int b){
return a + b;
}
//使用
int result = addBlock(3,5);
块的强大之处在于:在声明它的范围里,所有变量都可以为其所捕获。这就是说,块所在的范围里的全部变量,在块里依然可用。默认情况下,为块所捕获的变量,是不可以在块里修改的,声明变量的时候可以加上__block修饰符,这样就可以在块内修改了。对于实例变量,块总是能够修改的,所以对于要修改的实例变量则无需加__block.
块的保留环:块里面使用了实例变量或self,self也是个对象,因而块在捕获它时也会将其保留。如果self所指代的那个对象同时也保留了块,那么这种情况通常就会导致保留环。
2.全局块、栈块及堆块
定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义它的那个范围内有效。
void (^block)();
if(/* some condition */){
block = ^{
NSLog(@"Block A");
}
}else{
block = ^{
NSLog(@"Block B");
}
}
block();
定义在if及else语句中的两个块都分配在栈内存中。编译器会给每个块分配好栈内存,然而等离开了相应的范围之后,编译器有可能把分配给块的内存覆写掉。于是,这两个块只能保证在对于的if或else语句范围内有效。这样写出来的代码可以编译,但是运行起来时而正确,时而错误。若编译器未覆写待执行的块,则程序照常运行,若覆写,则程序崩溃。
为解决此问题,可给块对象发送copy消息来拷贝之。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象了。
//全局块
void (^blocks)(void) = ^{
// self.propert = @"string";//会报错,不会捕捉任何状态
};
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
}
除了“栈块”和“堆块”之外,还有一类块叫做“全局块”(global block)。这种块不会捕捉任何状态(比如外围的变量等),运行时也无需有状态来参与。块所使用的整个内存区域,在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。另外,全局块的拷贝操作是空操作,因为全局块决不可能为系统所回收。
要点:
- 块是C、C++、Objective-C中的词法闭包。
- 块可接受参数,也可返回值
- 块可以分配在栈或堆上,也可以是全局的。分配在栈上的块可拷贝到堆里,这样的话,就和标准的Objective-C对象一样,具备引用计数了。
38.为常用的块类型创建typedef
与其他类型的变量不同,在定义块变量时,要把变量名放在类型之中,而不是放在右侧。鉴于此,我们应该为常用的块类型起个别名。为了隐藏复杂的块类型,需要用到C语言中名为“类型定义”(type definition)的特性。typedif关键字用于给类型起个易读的别名。
typedef int(^BlockName)(BOOL flag, int value);
BlockName block = ^(BOOL flag, int value){
// block implementation
}
要点:
- 以typedef重新定义块类型,可令块变量用起来更加简单
- 定义新类型时应遵循现有的命名习惯,勿使用其名称与别的类型相冲突
- 不妨为同一个块签名定义多个类型别名。如果要重构的代码使用了块类型的某个别名,那么只需要修改相应typedef中的块签名即可,无需改动其他typedef。
39.用handler块降低代码分散程度
设计API时,对于回调的选择有多种,选用合适的回调方式能够让我们的代码更加清晰整洁。
要点:
- 在创建对象时,可以使用内联的handler块将相关业务逻辑一并声明
- 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用handler块来实现,则可直接将块与相关对象放在一起
- 设计API时如果用到了handler块,那么可以增加一个参数,使调用者可通过此参数来决定应该把块安排在哪个队列上执行。
40.用块引用其所属对象时不要出现保留环
要点:
- 如果块所捕获的对象直接或间接地保留了块本身,那么就得当心保留环问题
- 一定要找个适当的时机解除保留环,而不能把责任推给API的调用者。
41.多用派发队列,少用同步锁
在Objective-C中,如果有多个线程要执行同一份代码,那么有时可能会出现问题。这种情况下,通常要使用锁来实现某种同步机制,在GCD出现之前,有两种办法,第一种是采用内置的“同步块”(synchronization block);第二种是直接使用NSLock对象;
//同步块(synchronization block)
- (void)synchronizedMehtod{
@synchronized(self){
// safe 安全的执行代码
}
}
这种写法会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。这么写通常没错,因为它可以保证每个对象实例都能不受干扰地运行其synchronizationMehtod方法。然而,滥用@synchronized(self)则会降低代码效率,因为共用同一个锁的那些同步块,都必须按照顺序执行。若是在self对象上频繁加锁,那么程序可能要等另一段与此无关的代码执行完毕,才能继续执行当前代码,这样做其实并没有必要。
//NSLock对象
_lock = [[NSLock alloc] init];
- (void)synchronizedMethod{
[_lock lock];
//safe code
[_lock unlock];
}
也可以使用NSRecursiveLock这种“递归锁”(recursive lock),线程能够多次持有该锁,而不会出现死锁(deadlock)现象。这两种方法都很好,不过也有其缺陷。比方说,在极端情况下,同步块会导致死锁,另外,效率也不见得很高,而如果直接使用锁对象的话,一旦遇到死锁,就会非常麻烦。
有种简单而高效的办法可以代替同步块或锁对象,那就是使用“串行同步队列”。将读取操作及写入操作都安排在同一个队列里,即可保证数据同步。
_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue",NULL);
- (NSString *)someString{
__block NSString *localString;
dispatch_sync(_syncQueue,^{
localString = _someString;
});
return localString;
}
- (void)setSomeString:(NSString *)someString{
dispatch_sync(_syncQueue,^{
_someString = someString;
});
}
此模式的思路是:把设置操作与获取操作都安排在序列化的队列里执行,这样的话,所有针对属性的访问操作就都同步了。为了shi块代码能够设置局部变量,获取方法中用到了__block语法,若是抛开这一点,那么这种写法要比前面那些更为整洁。全部加锁任务都在GCD中处理,而GCD是在相当深的底层来实现的,于是能够做许多优化。因此,开发者无需担心那些事,只要专心把访问方法写好就行。
要点:
- 派发队列可用来表述同步语义(synchronization semantic),这种做法要比使用@synchronized块或NSLock对象更简单
- 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的形成
- 使用同步队列及栅栏块,可以令同步行为更加高效
42.多用GCD,少用performSelector系列方法
SEL selector = @selector(test);
[self performSelector:selector];
报警告:PerformSelector may cause a leak because its selector is unknown
原因在于:编译器并不知道将要调用的选择子是什么,因此,也就不了解其方法签名及返回值,甚至连是否有返回值都不清楚。而且,由于编译器不知道方法名,所以就没办法运用ARC的内存管理规则来判定返回值是不是应该释放。鉴于此,ARC采用了比较谨慎的做法,就是不添加释放操作。然而这么做可能导致内存泄漏,因为方法在返回对象时可能已经将其保留了。
- (id)performSelector:(SEL)aSelector;
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
传入的参数都是id类型,所以传入的参数必须是对象才行,基本数据类型不行;再者,返回值也是id。还有一个问题就是,多个参数的传递,我们可能需要使用字典等集合来进行封装再进行传递。
如果改为其他替代方案,那就不受这些限制了。最主要的替代方案就是使用块。
//using performSelector
[self performSelector:@selector(doSomeThing) withObject:nil afterDelay:5.0];
//using GCD
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
[self doSomeThing];
});
//using performSelector
[self performSelectorOnMainThread:@selector(doSomeThing) withObject:nil waitUntilDone:NO];
//using GCD
//if waitUntilDone is YES,then dispatch_sync
dispatch_async(dispatch_get_main_queue(), ^{
[self doSomeThing];
});
要点:
- performSelector系列方法在内存管理方法容易有疏失。它无法确定将要执行的选择子具体是什么,因而ARC编译器也就无法插入适当的内存管理方法。
- performSelector系列方法所能处理的选择子太多局限了,选择子的返回值类型及发送给方法的参数个数都受到限制
- 如果想把任务放另一个线程上执行,那么最好不要用performSelector系列方法,而是应该把任务封装到块里,然后调用大中枢派发机制的相关方法来实现。
43.掌握GCD及操作队列的使用时机
出了GCD之外,还有一种技术叫做NSOperationQueue,它虽然与GCD不同,但是却与之相关,开发者可以把操作以NSOperation子类的形式放在队列中,而这些操作也能并发执行。区别:GCD是纯C的API,而操作队列则是Objective-C的对象。在GCD中,任务用块来表示。用NSOperationQueue类的“addOperationWithBlock:”方法搭配NSBlockOperation类来使用操作队列,其语法与GCD方式类似。使用NSOperation及NSOperationQueue的好处如下:
- 可以取消某个操作。
- 指定操作间的依赖关系。
- 通过键值观测机制监控NSOperation对象的属性。
- 指定操作的优先级。
- 重用NSOperation对象
NSNotificationCeter使用的就是操作队列而非派发队列
- (id <NSObject>)addObserverForName:(nullable NSNotificationName)name object:(nullable id)obj queue:(nullable NSOperationQueue *)queue usingBlock:(void (^)(NSNotification *note))block
[[NSNotificationCenter defaultCenter] addObserverForName:(nullable NSNotificationName) object:(nullable id) queue:(nullable NSOperationQueue *) usingBlock:^(NSNotification * _Nonnull note) {
}];
要点:
- 在解决多线程与任务管理问题时,派发队列并非唯一方案。
- 操作队列提供了一套高层的Objective-C API,能实现纯GCD所具备的绝大部分功能,而且还能完成一些更为复杂的操作,那些操作若改用GCD来实现,则需另外编写代码
44.通过Dispatch Group机制,根据系统资源状况来执行任务
GCD常见方法
//创建队列组
dispatch_group_t group = dispatch_group_create();
//任务编组
//方式一:把待执行的任务块归属某个组
void dispatch_group_async(dispatch_group_t group,
dispatch_queue_t queue,
dispatch_block_t block);
//方式二:进组 与 出组 成对出现
void dispatch_group_enter(dispatch_group_t group);
void dispatch_group_leave(dispatch_group_t group);
//等待dispatch_group执行完毕
//arg0:等待的队列组 arg1:等待时间 DISPATCH_TIME_FOREVER表示一直等着dispatch_group执行完
//返回类型long,如果执行group所需的时间小于timeout,则返回0,否则返回非0值
long dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);
//通知队列组执行完后在指定的队列进行回调 与上面的方法相比,在非主队列中不会阻塞
void dispatch_group_notify(dispatch_group_t group,
dispatch_queue_t queue,
dispatch_block_t block);
//GCD遍历集合 该方法会持续阻塞,从0开始,直至iterations - 1
void dispatch_apply(size_t iterations, dispatch_queue_t queue,DISPATCH_NOESCAPE void (^block)(size_t));
要点:
- 一系列任务可归入一个dispatch group中。开发者可以在这组执行完毕时获得通知。
- 通过dispatch group,可以在并发式派发队列中同时执行多项任务。此时GCD会根据系统资源来调度这些并发执行的任务。开发者若自己来实现此功能,则需要编写大量代码。
45.使用dispatch_once来执行只需运行一次的线程安全代码
//单例中的dispatch_once使用
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
});
要点:
- 经常需要编写“只需要执行一次的线程安全代码”。通过GCD所提供的dispatch_once函数,很容易就能实现此功能。
- 标记应该声明在static或global作用域中,这样的话,在把只需执行一次的块传给dispatch_once函数时,传进去的标记也是相同的。
46.不要使用dispatch_get_current_queue
要点:
- dispatch_get_current_queue函数的行为常常与开发者所预期的不同。此函数已经废弃,只应做调试使用
- 由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念
- dispatch_get_current_queue函数用于解决由不可重入的代码所引发的死锁,然而能用此函数解决的问题,通常也能改用“队列特定数据”来解决
PDF格式的资料来自iOS开发交流群、感觉作者的贡献,对于知识的系统归纳总结很有帮助。
编写高质量代码的52个有效方法
编写高质量代码的52个有效方法(一)—熟悉OC
编写高质量代码的52个有效方法(二)—对象、消息、运行期
编写高质量代码的52个有效方法(三)—接口与API设计
编写高质量代码的52个有效方法(四)—协议与分类
编写高质量代码的52个有效方法(五)—内存管理
编写高质量代码的52个有效方法(六)—块与大中枢派发
编写高质量代码的52个有效方法(七)---系统框架