iOSiOS程序猿网络层

iOS GCD全析(五)

2018-11-19  本文已影响0人  ChinaChong

本文摘录自《Objective-C高级编程》一书,附加一些自己的理解,作为对GCD的总结。



此篇主要包含以下几个方面:


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自身会循环多次调用,直到你强制停止为止。

下面我们使用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

本章参考:

Parse源码浅析系列(一)---Parse的底层多线程处理思路:GCD高级用法

上一篇下一篇

猜你喜欢

热点阅读