iOS GCD全析(五)
本文摘录自《Objective-C高级编程》一书,附加一些自己的理解,作为对GCD的总结。
此篇主要包含以下几个方面:
-
Dispatch Source
- dispatch_source_t
- dispatch_source_create
- dispatch_source_set_event_handler
- dispatch_source_resume
- dispatch_source_cancel
- dispatch_source_set_timer
dispatch_source
GCD中除了主要的Dispatch Queue外,还有不太引人注目的Dispatch Source。它是BSD系内核惯有功能kqueue的包装。
kqueue 是在XNU内核中发生各种事件时,在应用程序编程方执行处理的技术。其CPU负荷非常小,尽量不占用资源。kqueue 可以说是应用程序处理XNU内核中发生的各种事件的方法中最优秀的一种。
Dispatch Source可处理以下事件。如表所示。
名称 | 内容 |
---|---|
DISPATCH_SOURCE_TYPE_DATA_ADD | 变量增加 |
DISPATCH_SOURCE_TYPE_DATA_OR | 变量OR |
DISPATCH_SOURCE_TYPE_MACH_SEND | MACH端口发送 |
DISPATCH_SOURCE_TYPE_MACH_RECV | MACH端口接收 |
DISPATCH_SOURCE_TYPE_READ | 可读取文件映像 |
DISPATCH_SOURCE_TYPE_WRITE | 可写入文件映像 |
DISPATCH_SOURCE_TYPE_PROC | 监测到与进程相关的事件 |
DISPATCH_SOURCE_TYPE_SIGNAL | 接收信号 |
DISPATCH_SOURCE_TYPE_TIMER | 定时器 |
DISPATCH_SOURCE_TYPE_VNODE | 文件系统有变更 |
事件发生时,在指定的Dispatch Queue中可执行事件的处理。
下面我们使用DISPATCH_SOURCE_TYPE_READ
,异步读取文件映像。
const char *fileName = "文件地址";
__block ssize_t total = 0;
/*
* 打开文件,获取文件描述符(open成功则返回文件描述符,否则返回-1)
*/
int fd = open(fileName, O_RDWR);
if (fd == -1) return;
/*
* 设定为异步映像
*/
fcntl(fd, F_SETFL, O_NONBLOCK);
/*
* 获取用于追加事件处理的 Global Dispatch Queue
*/
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
/*
* 基于READ事件作成Dispatch Source
*/
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, fd, 0, queue);
if (source == NULL) {
close(fd);
return;
}
/*
* 指定发生READ事件时执行的处理
*/
dispatch_source_set_event_handler(source, ^{
/*
* 预估要读取的字节数
*
* dispatch_source_get_data()函数的返回值
* 要根据dispatch_source_create()创建source时所选的类型而定,
*
* DISPATCH_SOURCE_TYPE_READ: estimated bytes available to read
*
* DISPATCH_SOURCE_TYPE_TIMER: number of times the timer has
* fired since the last handler invocation
*/
size_t estimatedSize = dispatch_source_get_data(source);
/*
* 这个buff就是向堆申请的一块内存,用来暂时缓存文件映像
* 参数是你要申请的内存大小,使用dispatch_source_get_data获取到的
* 是整个文件的大小,也可以一段一段读取,将参数写成
* size_t estimatedSize = 1024;
* 这个1024可以根据项目需要定义成其它数字,如100、1000、10000...
*/
void *buff = malloc(estimatedSize);
if (buff) {
/*
* 从映像中读取
*/
ssize_t length = read(fd, buff, estimatedSize);
total += length;
/*
* buff的处理
*
* buff可以读取任何文件,包括多媒体文件。通过NSData可进行转换。
*
* NSLog有个系统bug,打印不完整。printf可以打印完整。UITextView也可以呈现完整。
*/
NSData *data = [NSData dataWithBytes:buff length:length];
NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
// printf("%s\n=============\n", [text UTF8String]);
dispatch_async(dispatch_get_main_queue(), ^{
UITextView *textView = [[UITextView alloc] initWithFrame:self.view.bounds];
[self.view addSubview:textView];
textView.text = text;
});
free(buff);
/*
* 此处我设置的buff缓存区大小正好就是文件大小,
* 所以直接调用 dispatch_source_cancel(source) 让它直接结束。
* 因为这次读取的就是全部,不必再循环调用方法去读取了。
*/
dispatch_source_cancel(source);
}
});
dispatch_source_set_cancel_handler(source, ^{
close(fd);
NSLog(@"%zd", total);
});
/*
* 启动Dispatch Source
*/
dispatch_resume(source);
上面这段代码是参考了《Objective-C高级编程》和网上各位大神的博文,我做了整合后整理出的demo。
《Objective-C高级编程》书中的代码片段是一个大致思想,而各类博文中大多将方法拆分,看起来很高深(本人水平有限😅,看网上大神的文章好多时候都是直挠头)。用网上的源代码在自己的工程里运行的时候,有很多NSLog
都是null
,而按照《Objective-C高级编程》中的方式虽然没有null
,但是打印不全。于是找到了问题所在,不是代码有问题,而是NSLog
本身存在问题:大段的字符串打印不完全。使用printf
代替就会打印完整,而我用了一个更直观的替代方式,把字符串用UITextView
展示出来,这样就很容易看了。
顺便我去苹果官方文档看了一下dispatch_source_get_data
函数究竟是怎么回事,这是原文链接,我在上面的源码中带了注释,dispatch_source_get_data
函数的用途或者说返回值要视情况而定,这要看你创建dispatch_source_t
的时候选了什么参数类型,就是下面这句中的DISPATCH_SOURCE_TYPE_READ
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, fd, 0, queue);
使用DISPATCH_SOURCE_TYPE_READ
创建source时,dispatch_source_get_data
函数的返回值就是 estimated bytes available to read 即预估的可读取字节数。但是我一开始还是用的迷糊了,因为dispatch_source_get_data
函数的调用时机不能太随意,一定要在dispatch_source_set_event_handler
函数的回调中才会有效。《Objective-C高级编程》是这样写的代码片段,而我自己实践后发现确实是这样😓!
这里面还有一个坑就是buff,实际上buff代表的是你申请的一块堆内存,而buff本身是这块内存的首地址。让人迷糊的就是dispatch_source_set_event_handler
函数的block自身会循环多次调用,直到你强制停止为止。
-
当buff申请的大小正好是文件大小的时候,
dispatch_source_set_event_handler
函数不会重复执行,只执行一次就会结束。 -
当buff申请的大小比文件小的时候,就会不停的循环调用,直到所有文件内存读取完毕才会停止调用,或者你提前直接调用
dispatch_source_cancel
强制其停止。
下面我们使用DISPATCH_SOURCE_TYPE_TIMER
实现一个定时器
// 我假装搞了一个每秒执行一次,执行10次就停止的定时器
static dispatch_source_t timer;
static int count = 0;
- (void)startTimer {
timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
/*
* 设置定时器
* 第一个参数:定时器(dispatch_source_t)
* 第二个参数:定时器时间起点
* 第三个参数:定时器时间间隔
* 第四个参数:定时器允许延迟执行的时间
*/
dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 1ull * NSEC_PER_SEC, 0ull * NSEC_PER_SEC);
/*
* 指定定时器启动后要执行的任务
*/
dispatch_source_set_event_handler(timer, ^{
if (count++ < 10) {
NSLog(@"定时器任务");
}
else {
dispatch_source_cancel(timer);
}
});
/*
* 指定取消 Dispatch Source 时的处理
*/
dispatch_source_set_cancel_handler(timer, ^{
NSLog(@"定时器结束咯!");
});
dispatch_resume(timer);
}
相对于文件映像的异步读取来说,定时器就变得简单的很了,我能想到的都写在源码的注释里了,就不多说了。
看了异步读取文件映像用的源代码和这个定时器用的源代码后,有没有注意到什么呢?实际上Dispatch Queue没有“取消”这一概念。一旦将处理追加到Dispatch Queue中,就没有方法可将该处理去除,也没有方法可在执行中取消该处理。编程人员要么在处理中导入取消这一概念,要么放弃取消,或者使用NSOperationQueue等其他方法。
Dispatch Source与Dispatch Queue不同,是可以取消的。而且取消时必须执行的处理可指定为回调用的Block形式。因此使用Dispatch Source实现XNU内核中发生的事件处理要比直接使用kqueue 实现更为简单。在必须使用kqueue的情况下希望大家还是使用Dispatch Source,它比较简单。
那么Dispatch Source 与 Dispatch Queue 两者在线程执行上的是什么关系?
答案是:没有关系。两者会独立运行。 Dispatch Queue 像一个生产任务的生产者,而 Dispatch Source 像处理任务的消费者。可以一边异步生产,也可一边异步消费。你可以在任意线程上调用 dispatch_source_merge_data
以触发 dispatch_source_set_event_handler
。而句柄的执行线程,取决于你创建句柄时所指定的线程,如果你像下面这样创建,那么句柄会在主线程执行:
下面我们介绍DISPATCH_SOURCE_TYPE_DATA_ADD
相关用法
分派源提供了高效的方式来处理事件。首先注册事件处理程序,事件发生时会收到通知。如果在系统还没有来得及通知你之前事件就发生了多次,那么这些事件会被合并为一个事件。这对于底层的高性能代码很有用,但是OS应用开发者很少会用到这样的功能。类似地,分派源可以响应UNIX信号、文件系统的变化、其他进程的变化以及Mach Port事件。它们中很多都在Mac系统上很有用,但是iOS开发者通常不会用到。
不过,自定义源在iOS中很有用,尤其是在性能至关重要的场合进行进度反馈。使用时,首先创建一个源:自定义源累积事件中传递过来的值。累积方式可以是相加(DISPATCH_SOURCE_TYPE_DATA_ADD
), 也可以是逻辑或(DISPATCH_SOURCE_DATA_OR
)。自定义源也需要一个队列,用来处理所有的响应处理块。
创建源后,需要提供相应的处理方法。当源生效时会分派注册处理方法;当事件发生时会分派事件处理方法;当源被取消时会分派取消处理方法。
在同一时间,只有一个处理方法块的实例被分派。如果这个处理方法还没有执行完毕,另一个事件就发生了,事件会以指定方式(ADD或者OR)进行累积。通过合并事件的方式,系统即使在高负载情况下也能正常工作。当处理事件件被最终执行时,计算后的数据可以通过 dispatch_source_get_data
来获取。这个数据的值在每次响应事件执行后会被重置。
让GCD像OperationQueue那样暂停任务
demo的原作者微博@iOS程序犭袁
- (void)viewDidLoad {
//1.
// 指定DISPATCH_SOURCE_TYPE_DATA_ADD,做成Dispatch Source(分派源)。设定Main Dispatch Queue 为追加处理的Dispatch Queue
_processingQueueSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0,dispatch_get_main_queue());
__block NSUInteger totalComplete = 0;
dispatch_source_set_event_handler(_processingQueueSource, ^{
//当处理事件被最终执行时,dispatch_source_get_data获取的值是dispatch_source_merge_data第二个参数传过来的值。这个数据的值在每次响应事件执行后会被重置,所以totalComplete的值是最终累积的值。
NSUInteger value = dispatch_source_get_data(self->_processingQueueSource);
totalComplete += value;
NSLog(@"进度:%.2f", totalComplete/1000.0);
});
//分派源创建时默认处于暂停状态,在分派源分派处理程序之前必须先恢复。
dispatch_resume(_processingQueueSource);
self.running = YES;
//2.
//恢复源后,就可以通过dispatch_source_merge_data向Dispatch Source(分派源)发送事件:
_queue = dispatch_queue_create("com.example", DISPATCH_QUEUE_SERIAL);
for (NSUInteger index = 0; index < 1000; index++) {
dispatch_async(_queue, ^{
if (!self.running) {
return;
}
// 调用dispatch_source_merge_data以触发dispatch_source_set_event_handler回调
dispatch_source_merge_data(self->_processingQueueSource, 1);
usleep(10000);//0.01秒
});
}
}
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
[self changeStatus:self.running];
}
- (void)changeStatus:(BOOL)shouldPause {
if (shouldPause) {
[self pause];
} else {
[self resume];
}
}
- (void)resume {
if (self.running) {
return;
}
NSLog(@"✅恢复Dispatch Source(分派源)以及_queue");
self.running = YES;
dispatch_resume(_processingQueueSource);
if (_queue) {
dispatch_resume(_queue);
}
}
- (void)pause {
if (!self.running) {
return;
}
NSLog(@"🚫暂停Dispatch Source(分派源)以及_queue");
self.running = NO;
dispatch_suspend(_processingQueueSource);
dispatch_suspend(_queue);
}
运行结果:
且慢,寡人有一问
原作者微博@iOS程序犭袁在给队列添加任务的时候,使用的是串行队列。为什么不使用并发队列而是使用串行队列呢?
之前的《iOS GCD全析(四)》说过,dispatch_suspend
可以把GCD的队列(queue)和派发源(source)挂起。但是挂起队列有个问题就是即便调用dispatch_suspend
,任务也不会立即停止,在调用dispatch_suspend
之前已经开始执行的任务不会受到影响,仍然会继续执行。而在调用dispatch_suspend
之后还未开始执行的任务会受到影响,暂时不执行,进入暂停状态。
我们知道,串行队列中任务的特点就是队列中的任务会一个接一个执行,而并发队列中的任务在异步添加后会并发执行。demo中 for
循环1000次,也就是向串行队列添加了1000个任务,这1000个任务要一个接一个执行。
再看demo中的暂停方法 - (void)pause
,方法中先挂起了source
dispatch_suspend(_processingQueueSource);
然后再挂起了queue。这样在暂停的时候,就是先让source的回调先停下来,
dispatch_source_set_event_handler {
...
}
再让queue中的任务停下来。微博@iOS程序犭袁使用串行队列的好处就在这里,在队列挂起后,因为 dispatch_suspend
不能约束已经开始执行的任务,所以最多有一个任务在执行中,也就是说 “不听 dispatch_suspend
使唤的任务” 最多只有这一个尚在执行中的任务。
在恢复方法- (void)resume
中,则是先恢复了source,然后恢复了queue。这样一来,就在source已经恢复而queue还未恢复的这个时机,source的 dispatch_source_set_event_handler
回调有时间去处理那个 “不听 dispatch_suspend
使唤的任务” 那一次调用的 dispatch_source_merge_data
,再让queue中的任务继续。如此一来,队列queue和派发源source的暂停与恢复就会在表面上看起来真正的实现了,没有什么问题。
但是假如我们换成并发队列,情况就会变得很不一样。在调用 dispatch_suspend
想要暂停时,由于多个任务并发调用 dispatch_source_merge_data
,这就造成 “不听 dispatch_suspend
使唤的任务” 会有很多,而 dispatch_source_set_event_handler
回调又先行暂停,不能处理,这些任务调用的 dispatch_source_merge_data
就会先积压下。
在恢复的时候,dispatch_source_set_event_handler
回调会先去处理积压的 dispatch_source_merge_data
调用,这些调用会被合并,只执行一次。这样一来,在一次暂停继而恢复后,打印的进度就会缺失一些,因为有些被合并执行了。
细心的读者可能已经能发现了,如果换成并发队列,即便是不暂停,也一样会造成打印的进度有缺失的情况。因为多个 dispatch_source_merge_data
一起调用,根据source的特点,一定会被合并执行。
下面是换成并发队列的打印结果:
进度:0.128
进度:0.131
进度:0.133
进度:0.137
进度:0.138
进度:0.139
进度:0.140
进度:0.141
进度:0.142
进度:0.152
...
进度:0.955
进度:0.957
进度:0.960
进度:0.962
进度:0.966
进度:0.967
进度:1.000