前两篇文章学习分析了,GCD
队列和函数的使用方式、串行队列和并发队列的创建、同步函数和异步函数底层执行流程、串行队列的死锁、GCD
单例的实现流程等。本篇继续对GCD
的相关内容进行分析,如dispatch_barrier栅栏函数
、dispatch_semaphore信号量
、dispatch_group调度组
、dispatch_source事件源
等,将从使用和底层原理两个角度去分析这些内容。
1.栅栏函数
栅栏函数最直接的作用是控制任务执⾏顺序,达到同步的效果。
系统提供了两个函数:
-
dispatch_barrier_async
Submits a barrier block for asynchronous execution and returns immediately.
(提交一个屏障块以在调度队列上异步执行。) -
dispatch_barrier_sync
Submits a barrier block for synchronous execution on a dispatch queue.
(提交一个屏障块以在调度队列上同步执行。)
dispatch_barrier_sync
和dispatch_barrier_async
的区别也就在于会不会阻塞当前线程,同时需要注意的是,栅栏函数只能控制同一并发队列。
1.栅栏函数的使用
-
引入一个案例
自定义了一个并发队列,并且添加
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_async
现在有业务需求,确保
任务1
和任务2
先执行,才能执行任务3
,可以添加一个栅栏函数,见下面代码:添加一个栅栏函数
dispatch_barrier_async
,运行发现,该并发队列中的任务1
和任务2
一定会先于栅栏函数运行,在栅栏函数运行之后,才会运行任务3
。因为任务4
是在主队列,所以并不影响任务4
的正常执行。 -
添加栅栏函数
dispatch_barrier_sync
依然是上面的案例,将栅栏函数改成
dispatch_barrier_sync
,运行效果又是怎样呢?运行结果发现,依然满足业务需求,即
任务1
和任务2
一定会先于栅栏函数运行,在栅栏函数运行之后,才会运行任务3
。同时dispatch_barrier_sync
还有另外一个特点,会堵塞当前的线程,所以任务4
会在栅栏函数执行后才会被执行。 -
注意事项
- 栅栏函数和其他的任务必须在同一个队列中
- 不能使用全局并发队列
2.栅栏函数的底层原理
对于栅栏函数,我们知道可以起到同步的作用
,同时全局并发队列不能使用
,带着这两点,我们来分析源码,是不是这样。
我们以同步自定义并发队列为例,进行跟踪。
根据dispatch_barrier_sync
在libdispatch.dylib
源码中全局搜索,栅栏函数的实现。一路跟踪,最终会找到_dispatch_barrier_sync_f_inline
方法,这里跟踪流程不在描述。_dispatch_barrier_sync_f_inline
方法见下图:
下面会走到哪里呢?添加_dispatch_sync_f_slow
符号断点,成功进入到该方法,见下图:
这个方法已经很熟悉了,在分析同步函数执行流程和死锁的时候都分析过该方法,同时在调用这个方法时设置了DC_FLAG_BARRIER
的标签。_dispatch_sync_f_slow
方法见下图:
继续跟踪流程,再添加_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
的官方说明,见下图:
我们可以得出结论,信号量如果大于0
,表示可以控制GCD
的最大并发数。
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_signal
解锁发出信号,其他的任务可以执行,起到控制流程的作用。 -
案例3
信号量初始值变为
0
,也就是最大并发数设置为0
。dispatch_semaphore_wait
在主线程中,异步流程中停顿2
秒钟,正常情况下应该会先执行打印操作,number
输出等于0
才对,但是实际的情况是number
等于1
。见下图代码:原因和案例2是一致的,
dispatch_semaphore_wait
加锁阻塞了当前线程,dispatch_semaphore_signal
解锁后当前线程继续执行,number
输出结果为1
。
2.信号量原理分析
dispatch_semaphore_wait
和dispatch_semaphore_signal
加锁和解锁功能是如何实现的呢?带着这个问题,我们探索源码。
-
dispatch_semaphore_wait
原理实现源码见下图:
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
进行do-while
循环,当不满足条件时,会一直循环下去,从而导致流程的阻塞。这也就解释了上面案例2
和案例3
的执行结果。 -
dispatch_semaphore_signal
原理实现源码如下:
os_atomic_inc2o
是加操作,也就是对可用并发数据进行释放,将dispatch_semaphore_wait
获取的一个执行权限释放掉。当信号量初始值是0
时,调用加操作后,value
值大于0
,这样就可以获得执行权限。但是如果加一次后依然小于0
,则会报异常:Unbalanced call to dispatch_semaphore_signal()
。并调用_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
需要搭配起来使用。
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
,则后续的任务则不能正常运行。见下图:
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
很显然
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
封装。同样的思路,在
libdispatch.dylib
源码中搜索dispatch_group_async
的定义,见下图:调用了
_dispatch_continuation_group_async
方法,查看其实现:在该方法中,可以发现,在调用
dispatch_group_async
方法向组中添加任务时,就调用了dispatch_group_enter
方法,将信号量0
变成了-1
。那么如果需要将信号量重置,一定是在任务执行完毕后再调用
dispatch_group_leave
方法。继续跟踪代码,调用_dispatch_continuation_async
方法,其源码实现见下图:很熟悉啊!又回到了异步函数的流程了!具体异步函数分析过程见GCD函数和队列原理探索这里不再跟踪分析。
异步函数最终会调用
_dispatch_worker_thread2
方法,GCD函数和队列原理探索中也分析过,通过查看堆栈可以查看其运行流程。见下图:跟踪流程会调用
_dispatch_continuation_pop_inline
->_dispatch_continuation_invoke_inline
方法。_dispatch_continuation_invoke_inline
实现见下图:在该方法中会针对组的情况进行处理,调用
_dispatch_continuation_with_group_invoke
方法。见下图:在这里完成
_dispatch_client_callout
函数调用后,紧接着调用dispatch_group_leave
方法,将信号量由-1
变成了0
。
至此完成闭环,完整的分析了调度组
、进组
、出组
、通知
的底层原理和关系。
4.事件源
我们在日常开发中,经常会使用计时器NSTimer
,但是NSTimer
需要加入到NSRunloop
中,还受到mode
的影响。例如,如果选择的mode
是default
的话,当滑动scrollView
的时候,模式切换,定时器就会停止(当然可以将NSTimer
放到commonMode
中)。与此同时,如果Runloop
正在进行连续性的运行,timer
就可能会被延迟。
GCD
提供了一个解决方案dispatch_source源
。dispatch_source
有哪些特性呢?
- 时间较准确,
CPU
负荷小,占用资源少 - 可以使用子线程,解决定时器跑在主线程上卡
UI
问题 - 可以暂停,继续,不用像
NSTimer
一样需要重新创建
关键方法:
dispatch_source_create
创建源dispatch_source_set_event_handler
设置源事件回调dispatch_source_merge_data
源事件设置数据dispatch_source_get_data
获取源事件数据dispatch_resume
继续dispatch_suspend
挂起
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
当前是不是挂起状态,在设计代码逻辑时需要考虑到这两点。 -
2.事件源原理分析
带着上面使用过程中遇到的问题,我们来分析底层实现。
-
一个时间源正在运行,重复调用
dispatch_resume
会为什么会崩溃查找
dispatch_resume
的底层实现原理,见下图:紧接着调用了
_dispatch_lane_resume
方法,该方法的源码,见下图:通过解读源码发现,底层会对事件源的相关状态进行判断,如果其进行过度恢复,则会走到
over_resume
流程,直接调起DISPATCH_CLIENT_CRASH
崩溃。同时这里还维护了挂起计数,挂起计数包含所有挂起和非活动位的挂起计数。下溢意味着需要过度恢复或暂停计数转移到边计数,也就是说如果当前计数器还没有到可运行的状态,需要连续恢复。
-
连续挂起
从上面的案例中我们发现,连续挂起后需要对应次数的恢复过程才能执行,那么底层肯定是维护了一个信号量。首先搜索
dispatch_suspend
的实现,见下图:进入
dispatch_suspend
方法,会调动_dispatch_lane_suspend
方法,进入_dispatch_lane_suspend
的方法实现,见下图:通过下符号断点,发现下面会进入
_dispatch_lane_suspend_slow
流程。见下图:同样这里维护一个暂停计数,如果连续调用挂起方法,则会进行减法的无符号下溢。
至此GCD
的相关内容基本分析完成,如有新的知识点,后期再补充。