前言
上片文章分析了GCD
队列和函数的使用方式、串行队列和并发队列的创建、同步函数和异步函数底层执行流程、串行队列的死锁、GCD
单例的实现流程等。这篇文章我们继续探究dispatch_barrier栅栏函数
、dispatch_semaphore信号量
、dispatch_group调度组
、dispatch_source
事件源等,将从使用和底层原理两个角度去分析这些内容。
准备工作
1. 栅栏函数
1.1 常用的栅栏函数
-
dispatch_barrier_async
前面的任务执行完毕才会执行barrier
中的逻辑,以及barrier
后加入队列的任务。 -
dispatch_barrier_sync
作用相同,但是会堵塞线程
,影响后面的任务执行。
区别:dispatch_barrier_sync
和dispatch_barrier_async
的区别也就在于会不会阻塞当前线程
,同时需要注意的是,栅栏函数只能控制同一并发队列
。
1.2 栅栏函数的使用
自定义了一个并发队列
,并且添加3个异步函数
,加下面代码:
- (void)demo{
dispatch_queue_t concurrentQueue = dispatch_queue_create("test", DISPATCH_QUEUE_CONCURRENT);
/* 1.异步函数 */
dispatch_async(concurrentQueue, ^{
NSLog(@"1");
});
/* 2. 异步函数 */
dispatch_async(concurrentQueue, ^{
sleep(0.5);
NSLog(@"2");
});
// // 栅栏函数
// dispatch_barrier_async(concurrentQueue, ^{
// NSLog(@"----%@-----", [NSThread currentThread]);
// });
/* 3. 异步函数 */
dispatch_async(concurrentQueue, ^{
NSLog(@"3");
});
// 4
NSLog(@"4");
}
运行结果还是很明确,因为该队列是一个并发队列
,并且是异步函数
,所以任务1
、任务2
、任务3
、任务4
的执行顺序是混乱
的。见下面运行结果:
-
添加栅栏函数
dispatch_barrier_sync
如果现在有一个需求,确保任务1
和任务2
先执行,才能执行任务3
,可以添加一个栅栏函数
,见下面代码:
分析:
任务1
和任务2
一定会先于栅栏函数
运行,在栅栏函数
运行之后,才会运行任务3
。同时dispatch_barrier_sync
还有另外一个特点,会堵塞当前的线程
,所以任务4
会在栅栏函数执行后才会被执行
。 -
添加栅栏函数
dispatch_barrier_async
分析:
添加一个栅栏函数dispatch_barrier_async
,运行发现,该并发队列中的任务1
和任务2
一定会先于栅栏函数
运行,在栅栏函数
运行之后,才会运行任务3
。因为任务4
是在主队列
,所以并不影响任务4的正常执行
。 -
注意:
-
dispatch_barrier_sync
会阻塞当前线程 -
栅栏函数
和其他的任务必须在同一个队列中
-
不能使用全局并发队列
(后面会分析)
-
1.3 栅栏函数的底层原理
我们对栅栏函数的任务无非就是栅栏函数起到同步的作用
,全局并发队列不能够执行栅栏函数
。那我们分析一下源码,看看源码是怎么样的逻辑,请往下走。
在libdispatch.dylib
源码中全局搜索dispatch_barrier_sync
,一路往下跟踪最终找到了_dispatch_barrier_sync_f_inline
方法中,如下图:
通过下符号断点
_dispatch_sync_f_slow
,成功进入了该方法,说明栅栏函数是进入以上判断的,如下图:_dispatch_sync_f_slow
方法在之前同步函数执行
和死锁
时候已经分析过,同时在调用这个方法时设置了DC_FLAG_BARRIER
的标签。_dispatch_sync_f_slow
方法见下图:因为
func
基本不会为NULL
,那我们添加_dispatch_sync_invoke_and_complete_recurse
符号断点,发现的确进入了这个方法,如下:通过上面的运行堆栈,发现其流程为:
_dispatch_sync_f_slow
-> _dispatch_sync_invoke_and_complete_recurse
-> _dispatch_sync_complete_recurse
,最终定位到_dispatch_sync_complete_recurse
方法,见下图:分析:
栅栏函数的作用是起到同步,也就是说队列中之前的任务没有执行完,栅栏函数肯定是不会走的。所以在进行栅栏函数调用之前,肯定是要进行递归处理,完成队列中的任务
在
_dispatch_sync_complete_recurse
方法中,进行了递归处理
,如果当前存在barrier
,则会将当前队列中的任务全部唤醒执行
,调用dx_wakeup
。唤醒执行完毕后,才会执行_dispatch_lane_non_barrier_complete
,即当前队列任务已经执行完成了
,并且没有栅栏函数
,执行下面的流程。
想要执行栅栏函数之后的任务栅栏函数要先移除
,那么栅栏函数在哪里被执行或者被移除的呢?跟踪dx_wakeup
执行流程。dx_wakeup
是通过宏定义的函数,全局搜索并找到了定义的位置,见下图:
之前我们已经说过,
底层为不同类型的队列提供不同的调用入口
,那为什么全局并发队列不能够用栅栏函数呢?
继续往下看!
自定义并发队列
自定义并发队列会调用_dispatch_lane_wakeup
方法,定位源码,见下图:
首先会判断是否为
barrier
形式,如果是,则会调用_dispatch_lane_barrier_complete
方法,处理有栅栏函数的流程
;如果没有,则走正常的并发队列流程
,调用_dispatch_queue_wakeup
方法。
进入_dispatch_lane_barrier_complete
方法,查看流程,如下:
分析:
如果是
串行队列
,则会进行等待
,直到其他的任务执行完成,按顺序执行;如果是并发队列
,则会调用_dispatch_lane_drain_non_barriers
将栅栏之前的任务执行完成。最终调用_dispatch_lane_class_barrier_complete
方法,完成栅栏的清除
,从而执行栅栏之后的任务
。
全局并发队列
如果是全局并发队列
,dx_wakeup
方法对应的是_dispatch_root_queue_wakeup
方法,查看_dispatch_root_queue_wakeup
源码实现,见下图:
在全局并发队列流程中,
并没有栅栏函数的相关处理流程
,也就是按照正常的并发队列来处理
。总结:
全局并发队列为什么没有对栅栏函数进行处理呢?
因为全局并发队列除了被我们使用,系统也在使用,如果添加了栅栏函数,会导致队列运行的阻塞,从而影响系统级的运行,所以栅栏函数也就不适用于全局并发队列
。
2. 信号量
在使用GCD
过程中我们也会用到信号量(Dispatch Semaphore
),持有计数的信号。Dispatch Semaphore
提供了三个函数。
-
dispatch_semaphore_create
:创建一个Semaphore
并初始化信号的总量 -
dispatch_semaphore_wait
:可以使总信号量减1
,当信号总量为0
时就会一直等待
(阻塞所在线程
),否则就可以正常执行 -
dispatch_semaphore_signal
:发送一个信号
,让信号总量加1
,解锁
查看dispatch_semaphore_create
的API
相关说明如下:
我们可以得出结论,信号量如果
大于0
,表示可以控制GCD的最大并发数
。
2.1 信号量的使用
- 案例1
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_semaphore_t sem = dispatch_semaphore_create(1);
//任务1
dispatch_async(queue, ^{
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); // 等待
sleep(2);
NSLog(@"执行任务1");
NSLog(@"任务1完成");
dispatch_semaphore_signal(sem); // 发信号
});
// 任务2
dispatch_async(queue, ^{
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); // 等待、
sleep(2);
NSLog(@"执行任务2");
NSLog(@"任务2完成");
dispatch_semaphore_signal(sem); // 发信号
});
在全局并发队列
中,异步执行
相关的任务,当前Semaphore
的初始值为1
,也就是说当前队列最大并发数为1
。dispatch_semaphore_wait
表示阻塞
,或者说占用一个信号,dispatch_semaphore_signal
表示释放
,也就是释放所占用的信号。
-
案例2
对上面的案例进行一些调整,我们将信号量初始值变为0
,也就是最大并发数设置为0
。异步并发执行两个任务,并且任务延迟了2
秒钟,见下面代码:
如果没有加入信号量的话,一般情况都会先执行任务1然后再执行任务2。但是实际的情况相反。这里dispatch_semaphore_wait
有加锁
的作用,而dispatch_semaphore_signal
有解锁
作用。当执行任务1时,dispatch_semaphore_wait
加锁进行等待,当任务2执行完毕后,dispatch_semaphore_signa
l解锁发出信号,其他的任务可以执行,起到控制流程的作用。 -
案例3
信号量初始值变为0
,也就是最大并发数设置为0
。dispatch_semaphore_wait
在主线程中,异步流程中停顿2
秒钟,正常情况下应该会先执行打印操作,number
输出等于0
才对,但是实际的情况是number
等于1
。见下图代码:
原因和案例2是一致的,dispatch_semaphore_wait
加锁阻塞了当前线程
,dispatch_semaphore_signal
解锁后当前线程
继续执行,number
输出结果为1。
2.2 信号量原理分析
我们探究原理肯定是带着目的去的,那么我们以下就是主要探索dispatch_semaphore_wait
和dispatch_semaphore_signal
加锁和解锁功能是如何实现的,跟着走吧。
2.2.1 dispatch_semaphore_wait
原理
在libdispatch.dyld
中查找其实现源码如下:
分析:
os_atomic_dec2o
进行减操作
,也就是对创建是传入的value
值进行减操作
。以此来控制可并发数
。如果可并发数为
3
,则调用该方法后,变为2
,表示占用一个并发数
,剩下还可同时执行2个任务
。但是,如果初始值是0
,减操作之后为负数
,则会调动_dispatch_semaphore_wait_slow
方法。
_dispatch_semaphore_wait_slow
实现如下:
上面的案例中我们调用
dispatch_semaphore_wait
时,传入的flag
为DISPATCH_TIME_FOREVER
,表示一直等待
。进入_dispatch_sema4_wait
实现流程,如下图:分析:
由上图看出
_dispatch_sema4_wait
的实现是在lock
(锁)的相关文件,可以知道_dispatch_sema4_wait
是对锁进行操作的。_dispatch_sema4_wait
进行do-while
循环,当不满足条件
时,会一直循环下
去,从而导致流程的阻塞
。这也就解释了上面案例2
和案例3
的执行结果。
2.2.2 dispatch_semaphore_signal
原理
其实现代码如下:
os_atomic_inc2o
是加操作
,也就是对可用并发数据进行释放
,将dispatch_semaphore_wait
获取的一个执行权限释放掉
。当信号量初始值是
0
时,调用加操作后,value
值大于0
,这样就可以获得执行权限。但是如果加一次后依然小于0
,则会报异常:Unbalanced call to dispatch_semaphore_signal()
。并调用_dispatch_semaphore_signal_slow
方法。_dispatch_semaphore_signal_slow
实现如下:_dispatch_sema4_signal
同样会开启一个do-while
循环,直到满足条件可以运行为止
。Dispatch Semaphore
总结:
- 保持线程同步,将异步执行任务转换为
同步执行任务
- 保证
线程安全
,为线程加锁
3. 调度组
dispatch_group
,主要作用是控制任务的执行顺序
。提供了以下方法:
-
dispatch_group_create
创建组 -
dispatch_group_async
进组任务并执行 -
dispatch_group_notify
进组任务执行完毕通知 -
dispatch_group_wait
进组任务执行等待时间 -
dispatch_group_enter
进组 -
dispatch_group_leave
出组
注意:dispatch_group_enter
与dispatch_group_leave
必须要成对使用
3.1 调度组的使用
-
调度组案例
要求完成任务1
、任务2
、任务3
之后才能执行任务4
。使用调度组可以采用以下方式:
各个queue
加到group
里,然后当组中任务完成后再调用任务4
,这里使用了dispatch_group_wait
进行等待。dispatch_group_wait()
函数会一直等到前面group
中的内容执行完再执行下面内容,但会产生阻塞线程
的问题。这也就导致了主线程中的任务5
不能正常运行,直到任务组的任务完成才能被调用。 -
dispatch_group_notify
的使用
为解决上面的问题,可采用dispatch_group_notify
进行任务执行完毕的通知,见下图:
采用这种方式后,任务5不会被阻塞
,当任务组中的任务执行完毕后,再通知任务4
执行。 -
进组出组的使用
dispatch_group_enter
与dispatch_group_leave
搭配使用也可以完成上面的效果,见下图:
一个enter
必须对应一个leave
,成对出现
!当所有任务都执行完成并出组后,才会执行任务4
,并且不会阻塞任务5的执行
。
如果enter
和leave
没有成对出现,比如多了一个leave
则会崩溃
,见下图:
如果多一个进组
enter
,则后续的任务则不能正常运行
。见下图:3.2 调度组底层原理分析
dispatch_group_enter
进组和dispatch_group_leave
出组为什么能够起到与调度组dispatch_group_async
一样的效果呢?
-
dispatch_group_create
dispatch_group_create
方法实现见下图:
会调用_dispatch_group_create_with_count
方法,并默认传入0
,_dispatch_group_create_with_count
的实现见下图:
通过os_atomic_store2o
进行保存。 -
dispatch_group_enter
查看dispatch_group_enter
实现源码,见下图:
os_atomic_sub_orig2o
会进行--减减操作
,此时的old_bits等于-1
。 -
**
dispatch_group_leave
**
查看dispatch_group_leave
实现源码,见下图:
这里通过os_atomic_add_orig2o
,++加加操作
获取了old_state
,此时old_state
就等于0
。而0&DISPATCH_GROUP_VALUE_MASK
依然等于0
,也就是old_value
等于0
。与此同时,DISPATCH_GROUP_VALUE_1
的定义见下面代码:
#define DISPATCH_GROUP_VALUE_MASK 0x00000000fffffffcULL
#define DISPATCH_GROUP_VALUE_1 DISPATCH_GROUP_VALUE_MASK
#define DISPATCH_GROUP_VALUE_MASK 0x00000000fffffffcULL
很显然old_value
是不等于DISPATCH_GROUP_VALUE_MASK
的,所以流程会进入到外层的if
中,并调用_dispatch_group_wake
方法进行唤醒,唤醒的就是dispatch_group_notify
方法,也就是说,如果不调用dispatch_group_leave
方法,也就不会唤醒dispatch_group_notify
,下面的流程也就不会执行。
-
dispatch_group_notify
查看dispatch_group_notify
源码发现,在old_state
等于0
的情况下,才会去唤醒相关的同步异步函数执行
流程。见下图:
在dispatch_group_leave
分析中,我们已经得到old_state
结果等于0
所以这里也就解释了dispatch_group_enter
和dispatch_group_leave
为什么要配合起来使用
的原因,通过信号量的控制,避免异步的影响,能够及时唤醒并调用dispatch_group_notify
方法。 -
dispatch_group_async
的封装
为什么说dispatch_group_async
就等于dispatch_group_enter
和dispatch_group_leave
呢?一起探究一下dispatch_group_async
封装。
dispatch_group_async
的定义,见下图:
进入_dispatch_continuation_group_async
方法如下:
在调用dispatch_group_async
方法向组中添加任务时,就调用了dispatch_group_enter
方法,将信号量0
变成了-1
。
那么如果需要将信号量重置
,一定是在任务执行完毕后再调用dispatch_group_leave
方法。继续跟踪代码,调用_dispatch_continuation_async
方法,其源码实现见下图:
又回到了异步函数的流程了!具体异步函数分析过程见iOS GCD底层分析(1),这里不再跟踪分析。
异步函数最终会调用_dispatch_worker_thread2
方法,那么我们查看堆栈信息得到如下:
跟踪流程会调用
_dispatch_continuation_pop_inline
-> _dispatch_continuation_invoke_inline
方法。
先进入_dispatch_root_queue_drain方法,如下:
跟踪进入到
_dispatch_continuation_pop_inline
方法,如下:跟踪进入到
_dispatch_continuation_invoke_inline
方法,如下:跟踪进去
_dispatch_continuation_with_group_invoke
方法,如下:在这里完成
_dispatch_client_callout函数调用后
,紧接着调用dispatch_group_leave
方法,将信号量由-1
变成了0
。
注意:到此已经完整的分析了调度组
、进组
、出组
、通知
的底层原理和关系。
4. 事件源
在日常的开过程中,我们经常会用到NSTimer
。NSTimer
需要加入到NSRunloop
中,还受到mode
的影响。在mode
设置不对的情况下,scrollView
滑动的时候NSTimer
也会收到影响。如果Runloop
正在进行连续性的运行,timer
就可能会被延迟
。
GCD
提供了一个解决方案dispatch_source
源。dispatch_source
有以下几种特性:
- 时间较准确,
CPU
负荷小,占用资源少 - 可以使用
子线程
,解决定时器跑在主线程上卡UI问题
- 可以暂停,继续,不用像
NSTimer
一样需要重新创建
dispatch_source
源的关键方法:
-
dispatch_source_create
创建源 -
dispatch_source_set_event_handler
设置源事件回调 -
dispatch_source_merge_data
源事件设置数据 -
dispatch_source_get_data
获取源事件数据 -
dispatch_resume
继续 -
dispatch_suspend
挂起
4.1 事件源的使用
- 创建事件源
// 方法声明
dispatch_source_t dispatch_source_create(
dispatch_source_type_t type,
uintptr_t handle,
unsigned long mask,
dispatch_queue_t _Nullable queue);
// 实现过程
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_main_queue());
创建过程需要传入两个重要的参数:
-
dispatch_source_type_t
要创建的源类型 -
dispatch_queue_t
事件处理程序块将提交到的调度队列
事件源类型:
DISPATCH_SOURCE_TYPE_DATA_ADD
用于合并数据DISPATCH_SOURCE_TYPE_DATA_OR
按位OR用于合并数据DISPATCH_SOURCE_TYPE_DATA_REPLACE
新获得的数据值替换现有的DISPATCH_SOURCE_TYPE_MACH_SEND
监视Mach端口的调度源,只有发送权,没有接收权
-DISPATCH_SOURCE_TYPE_MACH_RECV
监视Mach端口的待处理消息DISPATCH_SOURCE_TYPE_MEMORYPRESSURE
监控系统的变化,内存压力状况DISPATCH_SOURCE_TYPE_PROC
监视外部进程的事件的调度源DISPATCH_SOURCE_TYPE_READ
监控文件描述符的调度源可供读取的字节DISPATCH_SOURCE_TYPE_SIGNAL
用于监视当前进程的信号DISPATCH_SOURCE_TYPE_TIMER
基于计时器的调度源DISPATCH_SOURCE_TYPE_VNODE
监视事件文件描述符的调度源DISPATCH_SOURCE_TYPE_WRITE
监视事件,写入字节的缓冲区空间事件源案例
使用dispatch_source
设计一个计时器,1
秒钟执行一次,能够暂停、开始,同时不受主线程影响
。见下图实现代码:
@interface ViewController ()
@property (nonatomic, strong) dispatch_source_t source;
@property (nonatomic, strong) dispatch_queue_t queue;
@property (nonatomic, assign) NSUInteger souceComplete;
@property (nonatomic) BOOL isRunning;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.souceComplete = 0;
// 开始时间
dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, 3.0 * NSEC_PER_SEC);
// 间隔时间
uint64_t interval = 1.0 * NSEC_PER_SEC;
// source
self.queue = dispatch_queue_create("test", NULL);
self.source = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0));
// 设置计时器
dispatch_source_set_timer(self.source, start, interval, 0);
__weak __typeof(self) weakSelf = self;
dispatch_source_set_event_handler(self.source, ^{
NSLog(@"source --- %lu ------ %@", (unsigned long)weakSelf.souceComplete++, [NSThread currentThread]);
});
// 默认启动
self.isRunning = YES;
dispatch_resume(self.source);
}
// 计时器控制
- (IBAction)didClickStartOrPauseAction:(id)sender {
if (self.isRunning) {
dispatch_suspend(self.source);
dispatch_suspend(self.queue);
self.isRunning = NO;
[sender setTitle:@"暂停中.." forState:UIControlStateNormal];
}else{
dispatch_resume(self.source);
dispatch_resume(self.queue);
self.isRunning = YES;
[sender setTitle:@"计时中.." forState:UIControlStateNormal];
}
}
@end
-
运行案例
注意事项:
Dispatch Source Timer
是间隔定时器
,也就是说每隔一段时间间隔定时器就会触发。在NSTimer
中要做到同样的效果需要手动把repeats
设置为YES
。dispatch_source_set_timer
中第二个参数,当我们使用dispatch_time
或者DISPATCH_TIME_NOW
时,系统会使用默认时钟
来进行计时。然而当系统休眠
的时候,默认时钟是不走的
,也就会导致计时器停止
。使用dispatch_walltime
可以让计时器按照真实时间间隔进行计时。dispatch_source_set_timer
的第四个参数leeway
指的是一个期望的容忍时间
,将它设置为1
秒,意味着系统有可能在定时器时间到达的前1
秒或者后1
秒才真正触发定时器。在调用时推荐设置一个合理的leeway
值。需要注意,就算指定leeway
值为0
,系统也无法保证完全精确的触发时间
,只是会尽可能满足这个需求
。event handler block
中的代码会在指定的queue
中执行。当queue
是后台线程的时候,dispatch timer
相比NSTimer
就好操作一些了。因为NSTimer
是需要Runloop
支持的,如果要在后台dispatch queue
中使用,则需要手动添加Runloop
。使用dispatch timer
就简单很多了。dispatch_source_set_event_handler
这个函数在执行完之后,block
会立马执行一遍,后面隔一定时间间隔再执行一次。而NSTimer
第一次执行是到计时器触发之后。这也是和NSTimer
之间的一个显著区别。-
停止
source
停止Dispatch Source
有两种方法,但是这两种方式在使用时有很大的区别:- dispatch_suspend
- dispatch_source_cancel
使用dispatch_suspend
时,source
本身的实例需要一直保持
。dispatch_suspend
之后的source
,是不能被释放
的,如果释放会崩溃
,见下图:
使用
dispatch_source_cancel
则没有这个限制,dispatch_source_cancel
是真正意义上的取消source
。被取消之后如果想再次执行source
,只能重新创建新的source
。这个过程类似于对NSTimer
执行invalidate
。见下图:-
source
挂起计数说明
dispatch_suspend
严格上只是把source暂时挂起
,它和dispatch_resume
是一个平衡调用
,两者分别会减少
和增加dispatch对象的挂起计数
。当这个计数大于0
的时候,source就会执行
。在挂起期间,产生的事件会积累起来,等到dispatch_resume
的时候会融合为一个事件发送
。
-
重复启动一个正在执行的源会崩溃
- 连续挂起,同样需要连续对应次数的启动才能够正常运行
注意:dispatch source并没有提供用于检测source本身的挂起计数的API,也就是说外部不能得知一个source当前是不是挂起状态,在设计代码逻辑时需要考虑到这两点。
4.2 事件源底层原理分析
通常我们分析原理都是带着问题触发的,那么这次我们探索根据以上的问题:为什么source
在运行时,重复调用dispatch_resume
方法就会崩溃?在以下我们看看底层原理就一清二楚了。
查找dispatch_resume
的底层实现原理,如下图:
接着进去
_dispatch_lane_resume
方法查看源码,如下:重复
resume
直接进入到了over_resume
方法里面,查看其实现如下:通过解读源码发现,底层会对事件源的
相关状态进行判断
,如果其进行过度恢复,则会走到over_resume
流程,直接调起DISPATCH_CLIENT_CRASH
崩溃。同时这里还维护了
挂起计数
(old_state
),挂起计数包含所有挂起和非活动位的挂起计数
。下溢意味着需要过度恢复或暂停计数转移到边计数,也就是说如果当前计数器还没有到可运行的状态,需要连续恢复。
- 连续挂起
我们发现,连续挂起后需要对应次数的恢复过程才能执行
,那么底层肯定是维护了一个信号量
。首先搜索dispatch_suspend
的实现,见下图:
接着进去_dispatch_lane_suspend
方法查看源码的实现,如下:
通过下符号断点发现会进入_dispatch_lane_suspend_slow
的流程,源码实现如下:
果不其然,同样这里维护一个暂停计数
,如果连续调用挂起方法
,则会进行减法的无符号下溢
。
总结
花了不少的时间,GCD
的探索就到此结束了,过程好艰辛但是收获也是满满的。iOS的底层学习任重而道远,继续努力。