Effective Objective-C 2.0 读书笔记——大中枢派发
多用派发队列,少用同步锁
说到同步锁,我们不难想起我们前面在学习线程之中的内容时学习到的关键字@synchronized
,使用这个同步块可以让我们这段程序实现加锁的操作,即在不同线程之中这个关键字的内容只能有一条线程运行。自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。以下是例子
-(void) synchronizedMethod {@synchronized (self) {///Safe}}
但是滥用@synchronized (self)会很危险,因为所有同步块都会彼此抢夺同一个锁。要是有很多个属性都这么写的话,那么每个属性的同步块都要等其他所有同步块执行完 毕才能执行,这也许并不是开发者想要的效果。我们只是想令每个属性各自独立地同步。
替代方案就是使用 GCD,它能以更简单、更高效的形式为代码加锁。比方说,属性就是开发者经常需要同步的地方,这种属性需要做成“原子的”。
初步代码
有种简单而高效的办法可以代替同步块或锁对象,那就是使用“ 串行同步队列” (serial synchronization queue)。将读取操作及写入操作都安排在同一个队列里,即可保证数据同步。 其用法如下:
// 创建同步队列(通常在初始化方法中创建)
_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;});
}
由于写操作(setter)不需要返回值,所以可以考虑使用 异步派发(dispatch_async) 替代同步派发,这样调用者就不必等待写操作完成,从而可能提升设置方法的执行速度。
异步写入
- (void)setSomeString:(NSString *)someString {// 异步派发写操作,调用者不会被阻塞dispatch_async(_syncQueue, ^{_someString = someString;});
}
优点:
- 调用者性能提升:因为写操作是异步执行的,调用者立即返回,不需要等待 block 执行完毕。
缺点:
- Block 拷贝开销:dispatch_async 会拷贝 block。如果拷贝 block 的成本超过 block 实际执行的时间,在简单的例子中可能会导致整体性能反而变慢。
- 适用场景:对于比较轻量的操作,异步写可能没有明显优势,但如果 block 内执行的任务较重或耗时,那么异步方式能使得调用者更快返回,提升整体效率。
用栅栏函数再优化
我们先前使用的就是串行队列,我们知道如果我们使用并行队列,那么性能将会更好,但是如果使用并行队列似乎就没办法完成安全读写这个点,于是我们就想起我们之前学习过的栅栏函数,将读写两个操作分开
// 创建一个并发队列,使用 barrier 确保写操作的独占性
_syncQueue = dispatch_queue_create("com.example.syncQueue", DISPATCH_QUEUE_CONCURRENT);- (NSString *)someString {__block NSString *localSomeString;// 使用同步派发保证读取时数据一致dispatch_sync(_syncQueue, ^{localSomeString = _someString;});return localSomeString;
}- (void)setSomeString:(NSString *)someString {// 使用 barrier 异步派发确保写操作独占执行dispatch_barrier_async(_syncQueue, ^{_someString = someString;});
}
我们多个读取操作变为异步执行,而写入操作则变成了单独执行,既避免了读取发生错误,又提高了程序的效率。
多用 GCD,少用performSelector系列方法
我们在讲述OC之中的动态性介绍过,performSelector
与选择子结合用于动态绑定,可简化复杂的代码
该方法与直接调用选择子等效。所以下面两行代码的执行效果相同:
[object performSelector: @selector (selectorName) ];
[object selectorName] ;
看似多余之举,实则如果选择子在运行期才能决定,就能体现其强大之处了
SEL selector;
if (/* some condition */) {selector = @selector(newObject);
} else if (/* some other condition */) {selector = @selector(copy);
} else {selector = @selector(foo);
}[object performSelector:selector];
但是这样写的代码也会出现一定的问题,我们前面也知道了在 Objective-C 中,根据方法命名规范(比如以 alloc
、new
、copy
、mutableCopy
开头的方法返回的对象属于调用者,需要手动释放),调用方法时返回的对象的内存管理责任是有约定的。那么不难理解这段代码之中的ret对象,在前两种情况需要被手动释放,最后一种则无需释放。那么在运行期才确定的内容,那么ARC就无法用简单的内存管理规则来管理相应内存,那么ARC在这种情况下会使用比较谨慎的做法,即不添加释放操作,那就会造成内存泄漏。
另外一点,这些方法的返回值只能是void或者是对象类型。如果调用的方法实际上返回了一个基本数据类型(如整数或浮点数),则必须通过一些复杂且容易出错的转换才能正确处理,因为这些数据类型与指针大小可能不一致。
由于 id
只是一个指向 Objective-C 对象的指针:
- 在 32 位系统上,返回值的大小只能是 32 位以内的数据。
- 在 64 位系统上,则只能是 64 位以内的数据。
- 如果返回的是一个 C 语言的结构体,而该结构体的大小超过了指针的大小,就不能使用 performSelector方法来调用,因为它无法正确返回超出指针大小的数据。
由于参数类型是id,所以传入的参数必须是对象才行。如果选择子所接受的参数是整数或浮点数,那就不能采用这些方法了。此外,选择子 最多只能接受两个参数,也就是调用performSelector: withObject: withObject:
这个版本。 而在参数不止两个的情况下,则没有对应的 performSelector
方法能够执行此种选择子。
书中给了两组示例展示了如何用 GCD 的块来实现与 performSelector 系列方法相同的功能,同时也解决了它们在内存管理和返回值类型上的一些限制。
延后执行任务
方案一:使用 performSelector:withObject:afterDelay:
// 延后 5 秒执行 doSomething 方法
[self performSelector:@selector(doSomething)withObject:nilafterDelay:5.0];
方案二(推荐):使用 dispatch_after:
// 计算延迟时间(5秒)
dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC));// 在主队列中延后 5 秒后执行 doSomething 方法
dispatch_after(delayTime, dispatch_get_main_queue(), ^{[self doSomething];
});
在主线程上执行任务
方案一:使用 performSelectorOnMainThread:withObject:waitUntilDone:
// 在主线程上异步执行 doSomething 方法
[self performSelectorOnMainThread:@selector(doSomething)withObject:nilwaitUntilDone:NO];
方案二(推荐):使用 dispatch_async:(若需要等待则用 dispatch_sync:)
// 在主队列上异步执行 doSomething 方法
dispatch_async(dispatch_get_main_queue(), ^{[self doSomething];
});
通过DispatchGroup 机制,根据系统资源状况来执行任务
dispatch_group_async
void dispatch_group_async (dispatch_group_t group, dispatch_queue_t queue,dispatch_block_t block) ;
下面是一个使用 dispatch_group_async
的例子,展示如何同时执行多个异步任务,并在所有任务完成后进行统一处理:
// 创建一个调度组
dispatch_group_t group = dispatch_group_create();// 获取全局并发队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);// 异步提交第一个任务到组中
dispatch_group_async(group, queue, ^{NSLog(@"任务1开始");// 模拟耗时操作sleep(2);NSLog(@"任务1完成");
});// 异步提交第二个任务到组中
dispatch_group_async(group, queue, ^{NSLog(@"任务2开始");sleep(3);NSLog(@"任务2完成");
});// 异步提交第三个任务到组中
dispatch_group_async(group, queue, ^{NSLog(@"任务3开始");sleep(1);NSLog(@"任务3完成");
});// 当组中所有任务执行完毕后,在主线程上执行回调
dispatch_group_notify(group, dispatch_get_main_queue(), ^{NSLog(@"所有任务完成,更新UI");
});
GCD 动态管理线程:
GCD 会根据当前系统资源状况(例如 CPU 核心数量、当前负载、队列中待执行任务数量等)自动创建新线程或复用旧线程。
- 如果你使用并发队列,并且有大量任务等待执行,GCD 可能会在多个线程上同时执行这些任务,以充分利用多核处理器的能力。
- 开发者不需要手动管理线程调度,GCD 会自动调整并发执行的程度,使得任务的并行度适应当前系统的资源状况。
调度组的作用:
通过 dispatch_group,你可以把一组任务“打包”,让它们并发执行,并在所有任务完成后收到通知。这样既利用了并发执行的优势,又能在全部任务结束后统一处理后续操作。
dispatch_apply 的使用
-
功能简介:
dispatch_apply
是另一种并发任务调度函数,它会对一个给定次数(iterations)重复执行一个 block,并把每次执行时的索引传给该 block。- 例如,如果你希望对数组中的每个元素执行相同的操作,可以用
dispatch_apply
替代常规的 for 循环。
- 例如,如果你希望对数组中的每个元素执行相同的操作,可以用
-
使用示例:
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); dispatch_apply(10, queue, ^(size_t i) {// 这里 i 从 0 到 9,每次执行 block 时 i 的值会递增NSLog(@"Iteration %zu", i);// 执行一些任务... });
或者针对数组:
NSArray *array = @[@"A", @"B", @"C", @"D"]; 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]; });
-
与常规 for 循环的比较:
- 使用简单的 for 循环也可以达到相同效果,但 dispatch_apply 的优势在于它直接利用 GCD 的调度机制,可以自动将任务分发到多个线程上并发执行(前提是使用并发队列)。
- 不过需要注意,
dispatch_apply
是一个阻塞调用,会等待所有任务执行完成后才返回,因此在性能测试中,若每个 block 的执行时间非常短,block 拷贝的开销反而可能使它比简单 for 循环更慢。
使用dispatch_once 来执行只需运行一次的线程安全代码
GCD引入了一项特性,能使单例实现起来更容易。所用的函数是:
void dispatch_once (dispatch_once_t *token,dispatch_block_t block);
此函数接受类型为dispatch_once_t的特殊参数,书中称之为标记,对于给定的标记来说,该函数保证相关的块必定会执行,且仅执行 一次。首次调 用该函数时,必然会执行块中的代码,最重要的 一点在于,此操作完全是线程安全的。请注 意,对于只需执行一次的块来说,每次调用函数时传人的标记都必须完全相同。
我们的单例模式相关方法可以写成以下形式:
+ (id) sharedInstance (
static EOCClass *sharedInstance = nil;
stat icdispatch_once_t onceToken;
dispatch_once (&onceToken, ^{sharedInstance = [[self alloc] init];
};
return sharedInstance;
}
使用dispatch _once可以简化代码并且彻底保证线程安全,开发者根本无须担心加锁 或同步。所有问题都由GCD 在底层处理。由于每次调用时都必须使用完全相同的标记, 所以标记要声明成static。把该变量定义在static作用域中,可以保证编译器在每次执行 sharedInstance
方法时都会复用这个变量,而不会创建新变量。
不要使用dispatch_get_current_ queue
概念不明确
“当前队列”这个概念并不总是明确的。当你调用 dispatch_get_current_queue()
时,它返回的队列可能是系统内部的私有队列,而不一定是你期望的那个队列。这会导致程序行为难以预测。
容易引发死锁
如果你在当前队列中同步调用某个任务,而这个任务又试图获取当前队列,就可能会导致死锁。由于对当前队列的依赖很容易形成循环依赖,使用 dispatch_get_current_queue()
增加了死锁风险。
dispatch_get_current_queue()
返回当前执行任务的队列。如果你使用它来获取队列,并用来进行同步调用,就有可能无意中对同一个队列调用 dispatch_sync
。例如:
dispatch_queue_t currentQueue = dispatch_get_current_queue();
// 当前任务在 currentQueue 上执行
dispatch_sync(currentQueue, ^{// 这个 block 也要在 currentQueue 执行// 但 currentQueue 正在执行外部的任务,无法中断执行
});
即使我们在编程的过程之中可以通过仔细观察从而避免以上的问题,但是书中提到的另一种情况则不是那么容易查出来的,这是书中提出的场景
排在队列B或队列C中的块,稍后会在队列A里依序执行。于是,排在队列A、B、C中 的块总是要彼此错开执行。然而,安排在队列D中的块,则有可能与队列A里的块(也包括队列B与C里的块)并行,因为A 与D的目标队列是个并发队列。若有必要,并发队列可以用多 个线程并行执行多个块,而是否会这样做,则需根据CPU的核心数量等系统资源状况来定。
排在队列C里的块,会认为当前队列就是队列C,而开发者可 能会据此认定:在队列A上能够安全地执行同步派发操作。但实际上,这么做依然会像前面 那样导致死锁。怎么解决这些问题呢?
GCD之中提供了一个队列特定数据(queue-specific data)的功能:
-
定义:
队列特定数据允许你将任意数据(以键值对形式)关联到一个特定的 GCD 队列上。这个数据就像“标签”一样,可以让你在执行任务时知道当前任务属于哪个队列。
-
查找机制:
当你调用
dispatch_get_specific(key)
时,系统不仅在当前队列中查找与该键关联的数据,还会沿着队列的目标队列(target queue)链进行查找,直到找到该数据或到达根队列为止。
dispatch_queue_t queueA =dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB =dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);// 将 queueB 的目标队列设置为 queueA
dispatch_set_target_queue(queueB, queueA);static int kQueueSpecific;
CFStringRef queueSpecificValue = CFSTR("queueA");// 在 queueA 上设置队列特定数据:键是 &kQueueSpecific,值是 queueSpecificValue
dispatch_queue_set_specific(queueA,&kQueueSpecific,(void*)queueSpecificValue,(dispatch_function_t)CFRelease);dispatch_sync(queueB, ^{dispatch_block_t block = ^{NSLog(@"No deadlock!");};// 从当前队列(或沿着目标队列链)获取与键 &kQueueSpecific 关联的数据CFStringRef retrievedValue = dispatch_get_specific(&kQueueSpecific);if (retrievedValue) {// 如果取到了数据,说明当前执行环境就是在我们想要的queueA之中block();} else {// 否则,说明当前队列不满足条件,就同步将 block 提交到 queueA 执行dispatch_sync(queueA, block);}
});
这种方式避免了直接使用 dispatch_get_current_queue()
带来的风险,同时保证了任务在正确的队列中执行,从而防止因线程上下文不对而引发的问题。