基础着手,探寻本质,一文聊透 GCD ,之后怕是难忘记

1,438 阅读14分钟

提到多线程,我们总是绕不开这些个概念:同步 & 异步队列串行队列 & 并行队列

概念

我们不妨先来了解下这些个概念:

  • 同步:阻塞线程等待,当前线程执任务,直至执行完成。
  • 异步:不阻塞线程,可开启新线程执行任务,继续执行后续任务。
  • 队列:似管道,一头进,一头出,一个接一个,不能并行,不能插队。
  • 串行队列:管子的出口,放出去一个后,必须等待这个完事儿了,再放下一个,就这样一个接一个直至全放完。
  • 并行队列:还是那根管子,放一个后,可以紧接着放下一个,下两个。。。取决于线程数,直至全放完。

从上面的总结能看出来他们的区别:

  • 同步 & 异步的区别主要在于:是否会阻塞当前线程,是否会开启新的线程。
  • 串行 & 并行的区别主要在于:放出去一个之后,是等待完成还是紧接着继续放。

下面,我们就来看看上面这些概念,怎么在 GCD 中来演绎。

基本操作

队列创建

我们先来看看 GCD 中所谓的队列,它是通过下面的方法创建:

dispatch_queue_t dispatch_queue_create(const char *label, dispatch_queue_attr_t attr);

可以看到,其包含有两个参数,分别为何意?官方文档 给了解释:

  • label:一个标签,字符串类型,附加到队列上的,有啥用呢?方便调试的时候去分辨去标识。并且推荐了命名方式(com.example.myqueue)。注意,这个参数是可选的,可以是 NULL
  • attr:指定队列类型,DISPATCH_QUEUE_SERIAL(or NULL)为串行,DISPATCH_QUEUE_CONCURRENT 为并行。

So,我们可以像这样去创建我们的队列:

// 串行队列的创建方法 
dispatch_queue_t queue = dispatch_queue_create("com.example.serialQueue", DISPATCH_QUEUE_SERIAL); 

// 并发队列的创建方法 
dispatch_queue_t queue = dispatch_queue_create("com.example.concurrentQueue", DISPATCH_QUEUE_CONCURRENT);

同步 & 异步

队列有了,我们就可以往里面塞任务了,怎么塞呢?

比如,我们可以像这样 同步 塞:

// 同步执行任务创建方法 
dispatch_sync(queue, ^{ 
    // 这里放同步执行任务代码 
}); 

还可以像这样 异步 塞:

// 异步执行任务创建方法 
dispatch_async(queue, ^{ 
    // 这里放异步执行任务代码 
});

这里实际上就是组合情况了,因为这里的 queue 可能是 串行队列 或者 并行队列

排列组合

排列组合的结果可能就会有下面这些情况,那么我们可以分析一下了:

  • 同步 + 串行队列:没什么悬念,阻塞当前线程一个接一个任务执行,队列中所有任务执行完成之后,再接着执行后面的代码。
  • 同步 + 并行队列:虽然这里是并行队列,但是因为是同步,阻塞当前线程,并不会开启新的线程,所以队列分发一个任务之后,并没有线程可供它继续分发后一个任务,只能等待直到前面任务执行完成,所以跟上面结果实际上是一致的。
  • 异步 + 串行队列:这里是异步执行,也就是会开启一个新线程去执行串行队列分发的任务,而当前线程会继续执行后续代码,尽管如此,队列里的任务还是会等待前面的任务执行完成后再次进行分发,这不是线程的问题了,是串行队列的特性,最终的结果是队列里的任务还是一个接一个执行,但是是在新开启的线程中,并非当前线程。
  • 异步 + 并行队列:到这里情况就会变得不一样了,因为是异步就不会阻塞在当前线程执行任务,会在并行队列分发一个任务的时候,开启一个线程去执行,而之后呢?并行队列会继续分发,然后继续开启线程去执行,直到队列里面的任务全部分发完成。需要注意的是,因为分发时候并不是等待前面任务执行完成的,所以所有任务执行的先后顺序就会变得不确定起来。

总结来看,可以有这么几条:

  1. 同步 或者 串行队列 队列里面的任务就会按照顺序去执行;
  2. 同步 就不会开启新线程,异步 才会开启线程;
  3. 线程的开启条数取决于队列给的任务数;

!! 这里需要思考的一个问题是:异步 + 并行队列 情况下,倘若并行队列里面的任务很多很多,那这时候会开启多少个线程呢?肯定的是最大值不可能是无穷大,那多少是个额定值呢?达到这个额定值后,并行队列还分发任务么?暂时先留个疑问。

嵌套组合

实际上,上面的组合并没有覆盖所有的日常使用场景,比如 嵌套 使用的情况。

怎么个意思呢?在上面 同步 & 异步 执行的任务代码,可能又是上面的排列组合情况,具体如下面这样:

// 同步执行任务创建方法 
dispatch_sync(queue, ^{ 
    // 这里放同步执行任务代码 
    // 同步执行任务创建方法 
    dispatch_sync(queue, ^{ 
        // 这里放同步执行任务代码 
    });
}); 

// 同步执行任务创建方法 
dispatch_sync(queue, ^{ 
    // 这里放同步执行任务代码 
    // 同步执行任务创建方法 
    dispatch_async(queue, ^{ 
        // 这里放同步执行任务代码 
    });
}); 

// 异步执行任务创建方法 
dispatch_async(queue, ^{ 
    // 这里放异步执行任务代码 
    // 同步执行任务创建方法 
    dispatch_sync(queue, ^{ 
        // 这里放同步执行任务代码 
    });
});

// 异步执行任务创建方法 
dispatch_async(queue, ^{ 
    // 这里放异步执行任务代码 
    // 同步执行任务创建方法 
    dispatch_async(queue, ^{ 
        // 这里放同步执行任务代码 
    });
});

这时候会发生什么?注意这里的 queue 依旧可能是 串行队列 或者 并行队列,那么可能的情况就是这样的(& 表嵌套):

  1. 同步 + 串行队列 & 同步 + 串行队列
  2. 同步 + 串行队列 & 异步 + 串行队列
  3. 同步 + 并行队列 & 同步 + 并行队列
  4. 同步 + 并行队列 & 异步 + 并行队列
  5. 异步 + 串行队列 & 同步 + 串行队列
  6. 异步 + 串行队列 & 异步 + 串行队列
  7. 异步 + 并行队列 & 同步 + 并行队列
  8. 异步 + 并行队列 & 异步 + 并行队列

看似情况变得很复杂,实际上只要抓住文章最前面提到的概念特性,很容易厘清。这里最重要的就是 同步 的阻塞执行及 串行队列 的阻塞派发,所以涉及到的组合情况就需要格外注意一下。

假设:外层任务记为 A,嵌套任务记为 B。

对于情况 1、2,在 同步 + 串行队列 中,因为队列依旧是串行队列,所以若是 同步 去执行嵌套的任务 (情况 1),会因其阻塞当前线程而导致任务 A 没法结束,从而 串行队列 无法派发任务 B,相互等待导致 死锁。若是 异步 (情况 2),因为不阻塞线程则会使之能顺利执行完任务 A,并在结束后开启新的线程来执行 串行队列 再次派发的任务 B(情况 2),这样,这种情况也会保证任务的执行顺序了(先 A 后 B)。

对于情况 3、4,在 同步 + 并行队列 中,因为此时变成了 并行队列,任务的分发不依赖前面任务的结束,所以 同步 执行嵌套任务的时候(情况 3),该队列会再次派发一个任务 B,需要注意的是,这时候就需要任务 B 执行完成之后,再继续外层任务的执行(同步的阻塞线程特性)(先 B 后 A)。而对于 异步 执行嵌套任务呢(情况 4)?队列依旧会派发任务 B,但是不会阻塞完成任务的执行了,会开启线程执行任务 B(先 A 后 B)。

对于情况 5、6,在 异步 + 串行队列 中,实际上情况跟 1、2 是一致的,这里的 异步 影响的是任务 A 的执行需要开启新的线程,嵌套内的情况并无变化。

对于情况 7、8,在 异步 + 并行队列 中,实际上情况跟 3、4 是一致的,同样这里的 异步 影响的是任务 A 的执行需要开启新的线程,嵌套内的情况并无变化。

所以,是不是并没有想象的乱套?理解其特性之后,复杂的情况就会变清晰很多。

那么问题来了,上面的嵌套情况队列都是保持一致的,那不一致的情况呢?或者再继续嵌套呢?留给大家一起分析分析,实际上都差不多,厘清之后再 coding 的时候,就不慌了。

主队列

这里有必要在提一下这个队列 -- 主队列

它是系统自己创建的,我们只能获取,其获取方法是这个:

dispatch_queue_main_t dispatch_get_main_queue(void) {
    return DISPATCH_GLOBAL_OBJECT(dispatch_queue_main_t, _dispatch_main_q);
}

看着是一个很特殊的队列,实际上他就是一个普通的 串行队列,只是他的任务是在 主线程 上执行的。

就像所谓的 主线程 一样,并不是这个线程特殊,只是将其标记为 主线程 而已,因为任务的执行有优先级,线程也需要有主次。

具体的执行情况,可以直接参照 串行队列 就可以了。

线程间通信

线程间通信,或者叫线程间跳转,实际上还是我们前面说到的 嵌套

只是这里的 queue 可能不是同一个了。

比如,我们常见的场景,子线程处理数据后主线程显示:

// 获取全局并发队列 
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); 

// 获取主队列 
dispatch_queue_t mainQueue = dispatch_get_main_queue(); 

dispatch_async(queue, ^{ 
    // 异步追加任务 1 
    sleep(2); // 模拟数据处理 
    
    // 回到主线程 
    dispatch_async(mainQueue, ^{ 
        // 在主线程模拟刷新UI 
    }); 
});

这里涉及到的就是 queuemainQueue 的切换。

我们在切换过程中需要格外注意的就是:

  • 避免相互等待造成的死锁;
  • 避免任务执行顺序紊乱不符合预期;

常用函数

需求的场景总是复杂的,为了保证完美切合需求的场景,GCD 也是封装了一系列边便捷的方式方法。

栅栏函数

我们可能会有这么一个需求的场景,任务 1,2 是可以并发的&无序的,任务 3,4 是可以并发的&无序的,但是任务 A 需要在 1,2 完成之后,并且 3,4 开始之前。

这里我们就可以用 栅栏函数 来轻松解决,比如这样:

// 创建并发队列
dispatch_queue_t queue = dispatch_queue_create("com.example.concurrentQueue", DISPATCH_QUEUE_CONCURRENT);

// 异步追加任务 1
dispatch_async(queue, ^{
    // 模拟耗时操作
    sleep(2);              
});

// 异步追加任务 2
dispatch_async(queue, ^{
    // 模拟耗时操作
    sleep(2);             
});

// 通过 栅栏函数 追加任务 A
dispatch_barrier_async(queue, ^{
    // 模拟耗时操作
    sleep(2);             
});

// 异步追加任务 3
dispatch_async(queue, ^{
    // 模拟耗时操作
    sleep(2);             
});

// 异步追加任务 4
dispatch_async(queue, ^{
    // 模拟耗时操作
    sleep(2);              
});

所以,栅栏函数 就好比是 栅栏,就此分割,从这里拦住任务的执行,前后的不管,管的是栅栏任务的前与后。

一次性函数

所谓的一次性函数,就是保证在 APP 进程中,只会执行一次的函数,而且是线程安全的。

这在我们日常开发中也是有对应的场景的,比如 单例,通常像下面这样:

+ (instancetype)shareInstance {
    static SomeClass * singleton = nil;
    static dispatch_once_t onceToken;
    // 通过一次性函数创建单一对象
    dispatch_once(&onceToken, ^{
        singleton = [[SomeClass alloc] init];
    });
    return singleton;

}

延时执行函数

我们通常也有这样的需求场景,某一任务的执行,需要在一定的延时之后,我们就可以通过 GCD 的延时函数来办:

// 2.0 秒后异步追加任务代码到主队列
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{  
    // 模拟耗时操作
    sleep(2);              
});

需要注意的是:这里的延时 2 秒,并不是 2 秒后开始执行任务,这里只是 2 秒后添加到指定的队列中,具体的执行需要由队列的派发决定。

快速迭代函数

我们通常遍历的时候会用 for 之类的循环去实现,而 GCD 给我们提供了另外一种方式:

void dispatch_apply(size_t iterations,
    dispatch_queue_t DISPATCH_APPLY_QUEUE_ARG_NULLABILITY queue,
    DISPATCH_NOESCAPE void (^block)(size_t iteration));

这里涉及到三个参数:

  • iterations:迭代的次数
  • queue:任务指定的队列
  • block:回调添加任务

我们可以像下面这样:

// 获取全局并发队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// 通过迭代函数添加任务
dispatch_apply(6, queue, ^(size_t index) {
    // 追加需要执行的任务
});

// ⚠️ 注意:这里会等待上面所有任务执行完成后,再继续后面的代码执行
// some things

这里值得一提的是,dispatch_apply() 会阻塞等待所有任务的执行完成,但追加的任务是不是按照顺序取决于队列是串行还是并行,串行则顺序执行,并行则顺序不定。而这,有时候正是我们需要的。

队列组

上面的 快速迭代函数 是会阻塞等待的,那么有没有不阻塞线程也能实现等待所有异步任务完成后再去执行后续任务呢?

显然是有的,这不是来了么?

比如,我们一个页面有多个请求的时候,我们如果想要等待所有的请求返回后再去刷新 UI 的话,可以这样:

// 获取全局并发队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

// 创建一个组
dispatch_group_t group =  dispatch_group_create();
// 向组内添加请求任务 1
dispatch_group_async(group, queue, ^{
    // 模拟请求耗时
    sleep(2); 
});

// 向组内添加请求任务 2
dispatch_group_async(group, queue, ^{
    // 模拟请求耗时
    sleep(2); 
});

// 等前面的异步任务 1、任务 2 都执行完毕后 通知回调
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    // 主线程中刷新 UI
});

// 后续任务不阻塞
// some things

信号量

当我们面对一堆杂乱的异步任务,比如章前面提到的 异步 + 并发队列,我们能让它们有序的去执行么?

它,信号量,就能搞!

在这之前我们来看看 信号量 相关的几个函数:

// 创建一个计数信号量,value 为初始值
dispatch_semaphore_t dispatch_semaphore_create(intptr_t value);

// 信号量计数+1,dsema 为信号量
intptr_t dispatch_semaphore_signal(dispatch_semaphore_t dsema);

// 信号量计数-1,dsema 为信号量,timeout 为超时时间
intptr_t dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);

需要注意一下的是:

  • dispatch_semaphore_create() 创建一个计数信号量,参数为初始值。
  • dispatch_semaphore_signal() 执行后,信号量 +1,同时会判断信号量增加前值是否小于 0(即增加后值是否小于等于0),若是则唤醒等待的线程去判断是否继续等待,并返回非零,否则不唤醒并返回 0.
  • dispatch_semaphore_wait() 会先把信号量 -1,然后判断其是否小于 0,小于 0 则等待,否则结束等待,返回 0 (成功)。另外,若出错,返回值是非零。

OK,下面我们来模拟一下前面提到的场景实现:

// 获取全局并发队列
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

// 创建一个计数信号量,初始值为 0
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
// 添加异步请求任务 1
dispatch_async(queue, ^{
    // 模拟请求耗时
    sleep(2); 
    dispatch_semaphore_signal(semaphore);
});

// 等待前面异步请求完成
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

// 添加同步请求任务 2
dispatch_sync(queue, ^{
    // 模拟请求耗时
    sleep(2); 
});

// 等待前面异步请求完成,同步的无需通过 dispatch_semaphore_wait()

// 添加异步请求任务 3
dispatch_async(queue, ^{
    // 模拟请求耗时
    sleep(2); 
    dispatch_semaphore_signal(semaphore);
});

// 等待前面异步请求完成
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

// 后续任务会阻塞
// some things

上面就能保证异步任务1、2、3 能按照顺序执行了。为什么?我们来分析一下。

起初,信号量为 0,接着添加了任务 1,因为是异步,并不阻塞,走到了 dispatch_semaphore_wait(),信号量自减为 -1,等待直到任务 1 执行完成后调用 dispatch_semaphore_signal(semaphore),信号量自增为 0,唤醒等待线程,判断为非小于零,继续后面的逻辑,添加同步任务 2,阻塞线程执行完同步任务 2 后,在添加异步任务 3,重复任务 1 的逻辑,这样就保证了其顺序。

当然,信号量的应用并不是仅限于此,实际上使用也很简单,但是还是要理解其函数的具体含义,以便灵活运用。

总结

截至目前,GCD 里面的一些基本概念我们就聊明白了,笔者认为,理解还是首要的,死记硬背不可取,会忘也会乱,刨根问底透彻理解其逻辑及含义,在 coding 的时候我们就能根据实际的需求场景灵活应用了。

以上,希望我们都能有所收获。