3

[TOC]

GCD是什么

Grand Central Dispatch 是苹果公司发布的一套多核多线程任务分发的解决方案,简称GCD,或者你叫他滚床单也没有人反对,嘿嘿。

GCD发布

苹果公司首次发布GCD是伴随Mac OS X 10.6 和 iOS 4系统一起发布的,也正是伴随着block块语法的支持,GCD技术将多线程执行代码,通过block封装成代码块,大大提高了多线程开发的效率,减少了开发难度,也极大增强了代码的可读性。

GCD之前的黑暗时代

如果我将GCD技术比喻成普罗米修斯带给人类的火种有一些夸张的话,至少可以将其比作火柴。而在没有生火器的石器时代,人类只能依靠何钻木取火。

POSIX线程

POSIX线程(pthread)是一套C语言编写的线程管理API,面向过程,我只在老东家一套C源码库中见别人用过,自己从来没有用过,也不会用,就像我也不会钻木取火一样。

NSThread

Cocoa框架中,用OC将pthread对象化封装,就诞生了NSThread操作类,但很可惜至今NSThread.h头文件中一行注释都木有,只能看出这个类早在1994年就已经存在了。

这里就不列举具体事例了,因为如今这个类的使用频率已经非常低了,唯一一种你可能会遇到的使用情境是判断当前执行线程是否为主线程,具体代码如下

  if([NSThread isMainThread]){
        
  }

但你在GCD和NSOperation出现之前,会在各种需要多线程处理的情况下,使用NSThread的隐式调用方法,也就是NSThread头文件中给NSObject 类作为属性方法扩展的一系列接口:

@interface NSObject (NSThreadPerformAdditions)

- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array;
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(nullable id)arg waitUntilDone:(BOOL)wait;
    // equivalent to the first method with kCFRunLoopCommonModes

- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait modes:(nullable NSArray<NSString *> *)array NS_AVAILABLE(10_5, 2_0);
- (void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(nullable id)arg waitUntilDone:(BOOL)wait NS_AVAILABLE(10_5, 2_0);
    // equivalent to the first method with kCFRunLoopCommonModes
- (void)performSelectorInBackground:(SEL)aSelector withObject:(nullable id)arg NS_AVAILABLE(10_5, 2_0);

@end

总计五个API,简易实现了一般开发需要使用的基本线程操作,避免用户自己动手写NSThread调度,引发的一些列莫名的死锁问题,在某种程度上减少了当时的多线程开发难度。

但这些API有一些很直观的问题,例如由于OC语言限制,这些API的参数传递、返回值获取都不易实现,并且实际写出来的代码也会因为逻辑跳转分布在文件的各个位置,影响阅读和纠错,你不相信请看我从教科书上抄下来的例子:

- (void)launchThreadByNSObject_performSelectorInBackground_withObject
{
    [self performSelectorInBackground:@selector(doWork) withObject:nil];
}

- (void) doWork
{
    /*
     *
     * 长时间处理
     *
     * 例如  图像处理
     *      网络数据请求
     *      大型数据库操作
     *      磁盘操作
     */
    
    //操作结束后调用主线程修改UI
    
    [self performSelectorOnMainThread:@selector(doneWork) withObject:nil waitUntilDone:NO];
}

- (void) doneWork
{
    //主线程修改UI
}

这个例子是一个解决关于主线程刷新UI问题的例子,我们同学都知道所有有关UI刷新的方法,务必要在主线程调用,这是个硬性要求,是因为UI渲染就是在主线程循环中完成的,如果在支线程中调用,会出现莫名其妙的错误、UI卡死或者程序崩溃。

所以多线程在我们的日常开发中,用得最多的地方,就是网络数据的异步请求,然后主线程刷新UI。将有延迟和计算量大的操作放在支线程完成,待完成后使用主线程刷新UI,才能有效地防止主线程UI刷新阻塞。

iOS 4与block

iOS 4带来的编译器对block块语法的支持,有点像人类发现了磷这种易燃物质一样,带来的是火柴(GCD 和 NSOperation)这个更简易的生火工具。

GCD和NSOperation 可以看作是 pthread(面向过程)和NSThread(面向对象)的block升级版本,带来的多线程编程体验则是质的飞跃。

GCD像是火柴,轻便易用,随用随取。NSOperation则像打火机,一次开发,重复使用。

GCD实战

好了,已经说了十几分钟废话了,终要进入主题进行GCD多线程开发实战。在开始之前,希望大家要提前学习block块语法的相关知识,不要求熟练使用,只要求看得懂。

实战一 异步加载

还记得我们在上面展示的从教科书上抄下来的例子么,这个例子如果改成GCD的版本,会是什么样子的呢?

//异步请求Dispatch
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    //长时间处理
    dispatch_async(dispatch_get_main_queue(), ^{
        //主线程更新UI
    });
});

这是什么鬼?我来解释一下。GCD使用的是C语言风格的调用接口,栗子中调用了两次dispatch_async方法,第一次将长时间处理操作分拨到支线程处理,在其完成后,跳转回主线程更新UI,操作都在方法的block参数中传入,简单明了,层级分明,没有传参障碍,没有阅读障碍,一气呵成,简直美极了。

dispatch_async方法传入的第二个参数是执行block,没啥好说的,第一个参数则是线程。dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)方法获取的Global线程,是非主线程中的一个,具体是哪个不用开发者操心,反正是系统认为这时候不是很忙的那一个。而两个传入参数中的第一个是线程的优先级(共四个优先级),第二个参数则约定为0。dispatch_get_main_queue()这个没有任何参数的方法,返回的则是主线程。

注意这里返回的参数类型是dispatch_queue_t,是一个普通变量,估计是线程的索引。真是两三句话就能讲明白的方法调用,什么你说听不懂、看不懂。无所谓呀~ 我们将这段代码加入代码片段,需要使用的时候拿出来用就行啦。

比如:

图片异步加载

首先放开权限NSAppTransportSecurity,NSAllowsArbitraryLoads

NSURLConnection版本

不加多线程异步操作

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];

    [cell.imageView setImage:[UIImage new]];
    
    NSURL* url = [NSURL URLWithString:self.static_data[indexPath.row]];
    NSData* data = [NSURLConnection sendSynchronousRequest:[NSURLRequest requestWithURL:url] returningResponse:nil error:nil];
    UIImage* image = [UIImage imageWithData:data];
    [cell.imageView setImage:image];
    [cell setNeedsLayout];

    return cell;
}

使用GCD以后

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];

    [cell.imageView setImage:[UIImage new]];
    
    NSURL* url = [NSURL URLWithString:self.static_data[indexPath.row]];

    dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSData* data = [NSURLConnection sendSynchronousRequest:[NSURLRequest requestWithURL:url] returningResponse:nil error:nil];
        UIImage* image = [UIImage imageWithData:data];
        
        dispatch_async(dispatch_get_main_queue(), ^{
            [cell.imageView setImage:image];
            [cell setNeedsLayout];
        });
    });

    return cell;
}
NSURLSession版本
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
    
//    cell.backgroundColor = [UIColor lightGrayColor];
    // Configure the cell...
    [cell.imageView setImage:[UIImage new]];
    
    NSURL* url = [NSURL URLWithString:self.static_data[indexPath.row]];
    NSURLSessionConfiguration* c = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSURLSession* session = [NSURLSession sessionWithConfiguration:c];
    NSURLSessionDataTask* task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        
        dispatch_async(dispatch_get_main_queue(), ^{
            UIImage* image = [UIImage imageWithData:data];
            //                NSLog(@"%@",image);
            //                NSLog(@"%@",cell.imageView);
            [cell.imageView setImage:image];
            [cell setNeedsLayout];
        });
        
    }];
    [task resume];
    return cell;
}

这次试出来UI刷新的阻塞感受了么?啊?你说没有,那你用真机调试一下,就会有更明显的感受了。

UI阻塞在实际开发中,偶尔会遇到。并且会引起一些莫名其妙的bug,希望大家再遇到时候能及时往这方面思考。比如,我们如果在UIViewController的初始化等一系列加载函数中加入能引起阻塞的代码,整个VC的加载会产生卡顿,还很有可能直接崩溃。

所以将阻塞操作放在支线程处理,是十分必要的。我们只要将下面代码存为代码片段,随用随取。

//支线程调用
dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            <#code#>
});

//主线程调用
dispatch_async(dispatch_get_main_queue(), ^{
        <#code#>
});

实战二 同步操作等待

多线程操作的第二个常用情景就是并行操作等待。

dispatch_group_t group = dispatch_group_create();
    // 合并汇总结果
    dispatch_group_async(group, dispatch_get_global_queue(0,0), ^{
            //并行阻塞操作1
        [NSThread sleepForTimeInterval:1.0];
        NSLog(@"1");
    });
    dispatch_group_async(group, dispatch_get_global_queue(0,0), ^{
        //并行阻塞操作2
        [NSThread sleepForTimeInterval:0.5];
        NSLog(@"2");
    });
    dispatch_group_async(group, dispatch_get_global_queue(0,0), ^{
        //并行阻塞操作3
        NSLog(@"3");
    });
    dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        //3项操作都完成后调用主线程更新UI
        NSLog(@"4");
    });

在这段演示代码里面,即使你看不懂GCD相关调用,也能猜出最后的输出结果对吧,我解释一下[NSThread sleepForTimeInterval:1.0];这句调用是让线程睡眠1秒中,模拟1秒钟阻塞。

好的告诉我你的答案。

3
2
1
4

这也是一段可以收藏为代码片段的实用工具,可以起名为并行代码等待。就像异步等待一样,我们现在来举一个简单的实际案例。

并行操作案例

还是举一个不是很简单的例子,也可能不是很实用,但绝对能体现这套逻辑的精髓。在讲栗子之前,我们先来学习一下SDWebImage的另外一段代码(对,又是SDWebImage)。

//SDImageCache.m 608行

- (NSUInteger)getSize {
    __block NSUInteger size = 0;
    dispatch_sync(self.ioQueue, ^{
        NSDirectoryEnumerator *fileEnumerator = [_fileManager enumeratorAtPath:self.diskCachePath];
        for (NSString *fileName in fileEnumerator) {
            NSString *filePath = [self.diskCachePath stringByAppendingPathComponent:fileName];
            NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:nil];
            size += [attrs fileSize];
        }
    });
    return size;
}

这段代码,具体功能是进行文件夹文件大小的统计。对你没有听错,文件夹是无法直接接获取其大小的,需要遍历其中每个文件然后相加统计。

这段代码实用GCD,但使用的方法我们前面并没有讲过,我放在后面再说。目前我们的任务是把这个方法改造一下,让他可以统计任意的文件夹大小。

- (NSUInteger)getSize:(NSString*)dicPath {
    __block NSUInteger size = 0;
    NSDirectoryEnumerator *fileEnumerator = [[NSFileManager defaultManager] enumeratorAtPath:dicPath];
    for (NSString *fileName in fileEnumerator) {
        NSString *filePath = [dicPath stringByAppendingPathComponent:fileName];
        NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:nil];
        size += [attrs fileSize];
    }
    return size;
}

接下来我们统计一下cache目录和tmp目录的容量

NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
NSString *cachesDir = [paths objectAtIndex:0];
NSString *tmpDir = NSTemporaryDirectory();
    
NSUInteger cacheSize = [self getSize:cachesDir];
NSUInteger tmpSize = [self getSize:tmpDir];
    
NSLog(@"total size : %@ (%@+%@)",@(cacheSize + tmpSize),@(cacheSize),@(tmpSize));

total size : 657060 (657060+0)

tmp文件夹是空的,我们换成libiary目录,不过因为cache目录在libiary目下,所以是有重复的,不过无所谓。

NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
NSString *cachesDir = [paths objectAtIndex:0];

NSArray * paths2 = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
NSString * libraryPath = paths2[0];
    
NSUInteger cacheSize = [self getSize:cachesDir];
NSUInteger librarySize = [self getSize:libraryPath];
    
NSLog(@"total size : %@ (%@+%@)",@(cacheSize + librarySize),@(cacheSize),@(librarySize));

我们在执行这段代码的时候,一般会很顺畅就执行完了,没有任何阻塞。原因是统计的目标目录,文件非常少。如果遇到文件稍多的情况,上面这段代码就出出现阻塞,又因为整个是在主线程操作的,所以必然会影响到UI的刷新,界面会卡顿。好,那让我们运用前面的GCD模版来将这段代码改造成异步执行。

NSLog(@"1");
    
dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
   
   NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
   NSString *cachesDir = [paths objectAtIndex:0];
   
   NSArray * paths2 = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
   NSString * libraryPath = paths2[0];
   
   NSUInteger cacheSize = [self getSize:cachesDir];
   NSUInteger librarySize = [self getSize:libraryPath];
   dispatch_async(dispatch_get_main_queue(), ^{
       
           NSLog(@"total size : %@ (%@+%@)",@(cacheSize + librarySize),@(cacheSize),@(librarySize));
       
   });
});
    
NSLog(@"2");

上面是我修改的结果,大家来分析一下输出顺序,应该是

1
2
total size : 1314324 (657060+657264)

前面坐了这么多铺垫,接下来我们进入正题,讲解一下串行和并行。串行很好理解,我们一般写的代码都是一步一步一串一串执行的。并行则是多项任务同时进行,也不难理解,类似于中学物理学的电路的并联合串联。

上面这段代码,我们前后调用两次getSize方法,按顺序分别统计了两个目录的大小,我们统计一下耗时:

dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
   
   clock_t begin, duration;
   
   NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
   NSString *cachesDir = [paths objectAtIndex:0];
   
   NSArray * paths2 = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
   NSString * libraryPath = paths2[0];
   
   begin = clock();
   
   NSUInteger cacheSize = [self getSize:cachesDir];
   NSUInteger librarySize = [self getSize:libraryPath];
   
   duration = clock() - begin;
   
   NSLog(@"%@",@((double)duration/CLOCKS_PER_SEC));
   
   dispatch_async(dispatch_get_main_queue(), ^{
       
           NSLog(@"total size : %@ (%@+%@)",@(cacheSize + librarySize),@(cacheSize),@(librarySize));
       
   });
});

0.002481

这里单位是秒,其实已经很快。 好,我们把前面的并行模版套进来。

dispatch_async( dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
   
   NSString *path = [[NSBundle mainBundle] bundlePath];
   
   NSArray * paths2 = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
   NSString * libraryPath = paths2[0];
   
   __block clock_t begin, duration;
   __block NSUInteger cacheSize,librarySize;
   
   dispatch_group_t group = dispatch_group_create();
   // 合并汇总结果
   
   begin = clock();
   dispatch_group_async(group, dispatch_get_global_queue(0,0), ^{
      
        cacheSize = [self getSize:path];
   });
   dispatch_group_async(group, dispatch_get_global_queue(0,0), ^{
   
        librarySize = [self getSize:libraryPath];
   });
   dispatch_group_notify(group, dispatch_get_main_queue(), ^{
   
        duration = clock() - begin;
       NSLog(@"%@",@((double)duration/CLOCKS_PER_SEC));
       NSLog(@"total size : %@ (%@+%@)",@(cacheSize + librarySize),@(cacheSize),@(librarySize));
   });
});

0.039834

提问

结果很让我欣慰,整整大了一个数量级,请你们分析一下原因。

原因也很简单,就是因为统计这种小目录是在耗时太短,短到比创建GCD Group的CPU占用都要少,所以耗时不降反增,呵呵。但一旦这个耗时任务CPU占用大于GCD消耗的时候,并行操作带来的耗时收益就是

串行总耗时 - 并行最大耗时

小结

这节课由于篇幅有限,我们讲的内容并不多,但实用性很高。大家注意到没有,从头到尾我们等于讲任何与GCD有关的接口调用、类型相关的内容,却教会了你进行异步请求和同步等待操作的方法,模版拿过来基本不用修改就能嵌套使用,这就叫知其然。下一章节我们再从API方向讲解GCD的类型和方法调用,这叫知其所以然。


秋刀生鱼片
2.1k 声望82 粉丝

独立游戏开发者