一、dispatch_group
有时候我们会有这样的需求:分别异步执行两个耗时任务,当两个耗时任务都完成以后回到主线程继续执行其他任务.这时候就可以用到GCD的队列组dispatch_group了.
Dispatch Group 会在整个组的任务都完成时通知你。这些任务可以是同步的,也可以是异步的,即便在不同的队列也行。而且在整个组的任务都完成时,Dispatch Group 可以用同步的或者异步的方式通知你。因为要监控的任务在不同队列,那就用一个 dispatch_group_t 的实例来记下这些不同的任务。
向队列组中添加任务有两种方式:
通过 dispatch_group_async 把任务放到队列中,然后将队列放入队列组中
或者使用队列组的 dispatch_group_enter、dispatch_group_leave 组合 来实现.
当组中所有的事件都完成时,GCD 的 API 提供了两种通知方式。
第一种是 dispatch_group_wait 回到当前线程继续执行,它会阻塞当前线程。
第二种是dispatch_group_notify回到指定线程执行任务.
我们先准备一个图片下载的异步网络请求:
- (instancetype)initwithURL:(NSURL *)url withCompletionBlock:(PhotoDownloadCompletionBlock)completionBlock{
static NSURLSession *session;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];
session = [NSURLSession sessionWithConfiguration:configuration];
});
NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
UIImage *image = [UIImage imageWithData:data];
NSLog(@"当前线程 %@",[NSThread currentThread]);
if (completionBlock) {
completionBlock(image, error);
}
}];
[task resume];
return self;
}
新建一个Photo类,实现上述实例方法,这是一个请求图片的异步耗时任务,图片下载好后,通过bolck回调回去;
现在,我们需要加载三张图片,当三张图片全部加载完成后,通知主线程刷新UI,在控制器中编写如下代码:
- (void)downloadPhotosWithCompletionBlock:(DownloadCompletionBlock)completionBlock
{
__block NSError *error;
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *urlArray = @[url1,url2,url3];
for (NSInteger i = 0; i < 3; i++) {
__unused Photo *photo = [[Photo alloc] initwithURL:urlArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
}];
}
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
}
用for循环创建三个下载任务,每下载好一张图片就存储到线程安全的单例中,等到全部图片下载完成后通过completionBlock
通知主线程更新UI.
编译运行:
2019-05-21 17:29:10.459040+0800 GCDDemo[22771:700230] 更新UI
2019-05-21 17:29:12.484551+0800 GCDDemo[22771:700513] 当前线程 <NSThread: 0x6000038c5980>{number = 3, name = (null)}
2019-05-21 17:29:15.766106+0800 GCDDemo[22771:700379] 当前线程 <NSThread: 0x6000038c6a80>{number = 4, name = (null)}
2019-05-21 17:29:15.766949+0800 GCDDemo[22771:700381] 当前线程 <NSThread: 0x6000038c6e80>{number = 5, name = (null)}
我们发现,更新UI的操作在图片下载完成前就执行了;问题的症结在于:你在方法的最后调用了 completionBlock ——因为此时你假设所有的照片都已下载完成。虽然Photo
类的实例方法是立即返回的,但是-[Photo initWithURL:withCompletionBlock:]
是异步执行的。
因此,只有在所有的图像下载任务都完成以后才能调用completionBlock
。但问题是:你该如何监控并发的异步事件?你不知道它们何时完成,而且它们完成的顺序完全是不确定的。
或许你可以写一些比较 Hacky 的代码,用多个布尔值来记录每个下载的完成情况,但这样做就缺失了扩展性,而且说实话,代码会很难看。
幸运的是, 解决这种对多个异步任务的完成进行监控的问题,恰好就是设计 dispatch_group 的目的。
对应实现如下:
- (void)downloadPhotosWithCompletionBlock:(DownloadCompletionBlock)completionBlock
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // 1
__block NSError *error;
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *urlArray = @[url1,url2,url3];
dispatch_group_t downloadGroup = dispatch_group_create(); // 2
for (NSInteger i = 0; i < 3; i++) {
dispatch_group_enter(downloadGroup); // 3
__unused Photo *photo = [[Photo alloc] initwithURL:urlArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_group_leave(downloadGroup); // 4
}];
}
dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER); // 5
dispatch_async(dispatch_get_main_queue(), ^{ // 6
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
});
});
}
按照注释的顺序,你会看到:
1.因为你在使用的是同步的 dispatch_group_wait ,它会阻塞当前线程,所以你要用 dispatch_async 将整个方法放入后台队列以避免阻塞主线程。
2.创建一个新的 Dispatch Group。
3.dispatch_group_enter 通知 Dispatch Group 有任务添加到队列组,每执行一次,Group 中未执行完毕任务数就会增加1。你必须保证 dispatch_group_enter 和 dispatch_group_leave 成对出现,否则你可能会遇到诡异的崩溃问题。
- dispatch_group_leave通知Dispatch Group 一个任务已经完成,使队列组中未执行的任务数减少1,当 group 中未执行完毕任务数为0的时候,会使dispatch_group_wait解除阻塞,转去执行dispatch_group_notify中的任务.
5.dispatch_group_wait 会一直等待,直到任务全部完成或者超时。如果在所有任务完成前超时了,该函数会返回一个非零值。你可以对此返回值做条件判断以确定是否超出等待周期;然而,你在这里用 DISPATCH_TIME_FOREVER 让它永远等待。它的意思是,永-远-等-待!这样很好,因为图片的创建工作总是会完成的。
6.此时此刻,你已经确保了,要么所有的图片任务都已完成,要么发生了超时。然后,你在主线程上运行 completionBlock 回调。这会将工作放到主线程上,并在稍后执行。
编译运行,结果也令人满意:
2019-05-21 14:18:13.715618+0800 GCDDemo[15558:455095] currentThread == <NSThread: 0x600003881080>{number = 3, name = (null)}
2019-05-21 14:18:13.720905+0800 GCDDemo[15558:455096] currentThread == <NSThread: 0x600003882540>{number = 4, name = (null)}
2019-05-21 14:18:13.722634+0800 GCDDemo[15558:455094] currentThread == <NSThread: 0x600003880e80>{number = 5, name = (null)}
2019-05-21 14:18:13.722882+0800 GCDDemo[15558:455012] 更新UI
目前为止的解决方案还不错,但在另一个队列上异步调度然后使用 dispatch_group_wait 来阻塞实在显得有些笨拙。是的,还有另一种方式……
在我们转向另外一种使用 Dispatch Group 的方式之前,先看一个简要的概述,关于何时以及如何组合不同的队列类型来使用 Dispatch Group :
- 自定义串行队列:它很适合当一组任务完成时发出通知。
- 主队列(串行):它也很适合这样的情况。但如果你要同步地等待所有工作地完成,那你就不应该使用它,因为你不能阻塞主线程。然而,异步模型是一个很有吸引力的能用于在几个较长任务(例如网络调用)完成后更新 UI 的方式。
- 并发队列:它也很适合 Dispatch Group 和完成时通知。
Dispatch Group,第二种方式: dispatch_group_notify
首先要知道dispatch_group_notify是异步工作的:
在 PhotoManager.m 中找到 downloadPhotosWithCompletionBlock: 方法,用下面的实现替换它:
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
__block NSError *error;
dispatch_group_t downloadGroup = dispatch_group_create(); // 1
for (NSInteger i = 0; i < 3; i++) {
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *urlArray = @[url1,url2,url3];
dispatch_group_enter(downloadGroup); // 2
__unused Photo *photo = [[Photo alloc] initwithURL:urlArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_group_leave(downloadGroup); // 3
}];
}
dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ // 4
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
});
}
下面解释新的异步方法如何工作:
1.在新的实现里,因为你没有阻塞主线程,所以你并不需要将方法包裹在 async 调用中。
2.同样的 enter 方法,没做任何修改。
3.同样的 leave 方法,也没做任何修改。
4.dispatch_group_notify 以异步的方式工作。当 Dispatch Group 中没有任何任务时,它就会执行其代码,那么 completionBlock 便会运行。你还指定了运行 completionBlock 的队列,此处,主队列就是你所需要的。
对于这个特定的工作,上面的处理明显更清晰,而且也不会阻塞任何线程。
太多并发带来的风险:
看看 PhotoManager 中的downloadPhotosWithCompletionBlock 方法。你可能已经注意到这里的 for 循环,它迭代三次,下载三个不同的图片。你的任务是尝试让 for 循环并发运行,以提高其速度。
dispatch_apply 刚好可用于这个任务。
dispatch_apply 表现得就像一个 for 循环,但它能并发地执行不同的迭代。这个函数是同步的,所以和普通的 for 循环一样,它只会在所有工作都完成后才会返回。
当在 Block 内计算任何给定数量的工作的最佳迭代数量时,必须要小心,因为过多的迭代和每个迭代只有少量的工作会导致大量开销以致它能抵消任何因并发带来的收益。而被称为跨越式(striding)的技术可以在此帮到你,即通过在每个迭代里多做几个不同的工作。
那何时才适合用 dispatch_apply 呢?
- 自定义串行队列:串行队列会完全抵消 dispatch_apply 的功能;你还不如直接使用普通的 for 循环。
- 主队列(串行):与上面一样,在串行队列上不适合使用 dispatch_apply 。还是用普通的 for 循环吧。
- 并发队列:对于并发循环来说是很好选择,特别是当你需要追踪任务的进度时。
回到 downloadPhotosWithCompletionBlock: 并用下列实现替换它:
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
__block NSError *error;
dispatch_group_t downloadGroup = dispatch_group_create(); // 1
dispatch_apply(3, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^(size_t i) { // 2
NSURL *url;
switch (i) {
case 0:
url = [NSURL URLWithString:@"URL1"];
break;
case 1:
url = [NSURL URLWithString:@"URL2"];
break;
case 2:
url = [NSURL URLWithString:@"URL3"];
break;
default:
break;
}
dispatch_group_enter(downloadGroup); // 3
__unused Photo *photo = [[Photo alloc] initwithURL:url
withCompletionBlock:^(UIImage *image, NSError *_error)
{
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_group_leave(downloadGroup); // 4
}];
});
dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ // 5
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
});
}
你的循环现在是并行运行的了;
在上面的代码中,在调用 dispatch_apply 时,第一个参数指明了迭代的次数,第二个参数指定了任务运行的队列,而第三个参数是一个 Block。
要知道虽然你有代码保证添加相片时线程安全,但图片的顺序却可能不同,这取决于线程完成的顺序。
编译并运行,
至于要不要将循环写成并行运行的,我们要考虑的就是新建线程所带来的开销是否比执行for循环小得多...
实际上,在这个例子里并不值得。下面是原因:
- 你创建并行运行线程而付出的开销,很可能比直接使用
for
循环要多。若你要以合适的步长迭代非常大的集合,那才应该考虑使用dispatch_apply
。 - 你用于创建应用的时间是有限的——除非实在太糟糕否则不要浪费时间去提前优化代码。如果你要优化什么,那去优化那些明显值得你付出时间的部分。你可以通过在 Instruments 里分析你的应用,找出最长运行时间的方法。
- 通常情况下,优化代码会让你的代码更加复杂,不利于你自己和其他开发者阅读。请确保添加的复杂性能换来足够多的好处。
二、GCD信号量 : dispatch_semaphore
GCD中的信号量是指dispatch_semaphore,是一种多线程技术.
信号量让你控制多个访问者对有限数量资源的访问。举例来说,如果你创建了一个有着两个资源的信号量,那同时最多只能有两个线程可以访问临界区。其他想使用资源的线程必须在FIFO队列里等待。
dispatch_semaphore 通过信号量控制着线程对临界区的访问,当计数信号量大于等于零时,允许访问,计数信号量小于零时,通过阻塞禁止访问.
dispatch_semaphore_create提供三个函数:
- 创建信号:dispatch_semaphore_create(0);
1.使用初始值创建新的计数信号量,初始值不可以小于0。
2.当两个线程需要协调特定事件的完成时,为值传递0是有用的。
3.传递大于零的值对于管理有限的资源池非常有用,其中池大小等于该值。
- 发送信号:dispatch_semaphore_signal(semaphore);
增加计数信号量。如果前一个值小于零,这个函数将唤醒当前在dispatch_semaphore_wait中等待的线程。
- 等待信号:dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
减少计数信号量。如果结果值小于零,则此函数将等待信号出现后返回。
注意:dispatch_semaphore_signal调用必须与dispatch_semaphore_wait调用保持平衡。试图处理计数值小于0的信号会导致EXC_BAD_INSTRUCTION异常。
信号量在实际开发中的用途主要有:
1.线程同步
2.线程加锁
3.控制线程并发数
在上面的示例中,三张图片被下载是异步的,自然图片下载完成的顺序也就无法保证.在实际开发中,可能会遇到要按序下载图片的需求,比如将一张大图分解为多张小图下载然后在客户端重新拼接,这样就要求我们保证下载的顺序.
- (void)testSemaphore{
dispatch_queue_t queque = dispatch_queue_create("com.testSemaphore", DISPATCH_QUEUE_CONCURRENT); // 1
dispatch_async(queque, ^{ // 2
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *imageArray = @[url1,url2,url3];
for (NSInteger i=0;i<imageArray.count;i++) {
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); // 3
__block NSError *error;
__unused Photo *photo = [[Photo alloc] initwithURL:imageArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_semaphore_signal(semaphore); // 4
}];
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); // 5
}
dispatch_async(dispatch_get_main_queue(), ^{ // 6
NSLog(@"更新UI");
[self updateUIWithArray:[[PhotoManager sharedManager] photos]];
});
});
}
我们看一下信号量是如何工作的:
1.首先创建一个并发队列;
2.创建一个异步任务,添加到队列中;
3.dispatch_semaphore_create
初始化信号为0,第一张图片开始异步加载;
4.任务执行到dispatch_semaphore_wait
信号量计数减1, 此时信号为-1,线程在此等待;
5.当有图片下载完成, dispatch_semaphore_signal 使总信号量+1,刚刚被阻塞的线程恢复执行,程序进行下一次循环,开始下载第二张图片....图片得以顺序下载;
6.图片全部下载完成或者超时停止,回到主线程更新UI.
如果你的代码所在的进程中有多条线程在运行,这些线程就有机会同时执行你的这段代码,如果这段代码不是线程安全的,那将会引发诡异的异常发生.
就像经典的卖票问题,票源的数量是固定的,我们同时开启多条线程同时卖票,对于查询余票的方法,余票的数量是一个全局的变量,就会面临线程安全问题,因为它可以被多条线程同时访问.
我们先在viewDidLoad
中开几条线程,异步访问此卖票方法,来模拟卖票的情景:
- (void)viewDidLoad {
[super viewDidLoad];
self.tickesCount = 10;
dispatch_queue_t queue1 = dispatch_queue_create("com.TickesQueue1", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue2 = dispatch_queue_create("com.TickesQueue2", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue3 = dispatch_queue_create("com.TickesQueue3", DISPATCH_QUEUE_SERIAL);
__weak __typeof__ (self) weakSelf = self;
dispatch_async(queue1, ^{
for (int i = 0; i < 5; i ++) {
[NSThread sleepForTimeInterval:1];
[weakSelf tickesSell];
}
});
dispatch_async(queue2, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
dispatch_async(queue3, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
}
下面是卖票方法的具体实现:
- (void)tickesSell
{
if (self.tickesCount > 0) { //如果还有票,继续售卖
[NSThread sleepForTimeInterval:1];//模拟出票
self.tickesCount--;
NSLog(@"剩余票数:%ld 访问线程%@", (long)self.tickesCount, [NSThread currentThread]);
} else { //如果已卖完,关闭售票窗口
NSLog(@"没有票啦 !!!");
}
}
运行结果:
发现整个运行是错乱的,而且出现了余票为负数的错误情况.
分析发现,卖票方法不是线程安全的. 我们在多个线程同时调用这个方法,有一个可能性是在某个线程(线程1)上进入 if
语句块并且此时 tickesCount
恰好为1(既当前仅剩一张余票),接下来执行出票任务,卖出了最后一张票,但还没来得及修改余票数.此时发生一个上下文切换;然后在另一个线程(线程2)进入 if
条件分支,此时tickesCount
却依然是1,准备执行出票任务。
接着,系统上下文切换回线程1
,这时线程1
才将余票tickesCount
的值修改为0.
随后系统上下文再切回线程2
执行出票任务,继续将余票数量减1,很明显程序会在此时发生异常,因为此时已经没票了.这是一个很明显的资源争夺问题,在多线程中较为常见.
信号量可以控制线程对于临界区的访问,而将过多的线程访问放在FIFO队列中进行排队.刚好可以用来解决这个问题:
semaphore为为线程加锁
在viewDidLoad
中调用dispatch_semaphore_create(1)初始化semaphore,其他代码保持不动;
- (void)viewDidLoad {
[super viewDidLoad];
self.tickesCount = 10;
self.semaphore = dispatch_semaphore_create(1);
dispatch_queue_t queue1 = dispatch_queue_create("com.TickesQueue1", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue2 = dispatch_queue_create("com.TickesQueue2", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue3 = dispatch_queue_create("com.TickesQueue3", DISPATCH_QUEUE_SERIAL);
__weak __typeof__ (self) weakSelf = self;
dispatch_async(queue1, ^{
for (int i = 0; i < 5; i ++) {
[NSThread sleepForTimeInterval:1];
[weakSelf tickesSell];
}
});
dispatch_async(queue2, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
dispatch_async(queue3, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
}
我们替换卖票的方法:
- (void)tickesSell
{
dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER); // 1
if (self.tickesCount > 0) { //如果还有票,继续售卖 // 2
[NSThread sleepForTimeInterval:1]; // 3
self.tickesCount--; // 4
NSLog(@"剩余票数:%ld 访问线程%@", (long)self.tickesCount, [NSThread currentThread]);
} else { //如果已卖完,关闭售票窗口
NSLog(@"没有票啦 !!!");
}
dispatch_semaphore_signal(_semaphore); // 5
}
编译运行:
对于上面代码执行的理解:
在viewDidLoad
中初始化semaphore
,初始值信号计数设置为1
;
1.在此处执行dispatch_semaphore_wait
,使信号量减一变为0,阻断新的线程进入,被阻断的线程进入队列中进行等待,等待信号开启;
2.判断是否还有余票;
3.sleepForTimeInterval
耗时任务模拟卖票过程;
4.出票以后,将票源数量减一;
5.执行完一次完整卖票任务,激活信号量,使其值增加为1,允许线程进入访问;
我们通过控制信号量维持在1和0之间,来接受和拒绝线程访问,进而达到线程同步的目的,通过上面的思路是不是也就说明了我们可以通过信号量来达到在GCD中控制线程数的目的?
答案是肯定的:
- (void)maxConcurrent
{
dispatch_semaphore_t semaphore = dispatch_semaphore_create(10); // 1
dispatch_queue_t queue = dispatch_queue_create("com.maxConcurrent.Test", DISPATCH_QUEUE_CONCURRENT); // 2
for (int i = 0; i < 100; i++) // 3
{
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); // 4
dispatch_async(queue, ^{ // 5
[NSThread sleepForTimeInterval:0.1*i]; // 6
NSLog(@"当前执行任务:%d 当前线程:%@",i+1,[NSThread currentThread]);
dispatch_semaphore_signal(semaphore); // 7
});
}
}
按照注释的顺序,你会看到:
1.初始化一个信号量,信号量初始值为10,这将允许最多10个线程同时访问;
2.开启一个并发队列,用于接收大量的异步任务;
3.创建一个100次的循环;
- dispatch_semaphore_wait使信号量减1;
5.将任务加入队列;
[参考]:
Objective-C高级编程,iOS和OS X多线程和内存管理;
@nixzhu在Github上的译文,
https://github.com/nixzhu/dev-blog/blob/master/2014-04-19-grand-central-dispatch-in-depth-part-1.md
原作者:Derek Selander
原文地:[http://www.raywenderlich.com/60749/grand-central-dispatch-in-depth-part-1]