奇怪的GCD

原文地址html

多线程一直是我至关感兴趣的技术知识之一,我的尤为喜好GCD这个轻量级的多线程解决方案,为了了解其实现,不厌其烦的翻阅libdispatch的源码。甚至由于太喜欢了,原本想要写这相应的源码解析系列文章,但惧怕写的很差,因而除了开篇的类型介绍,也是草草了事,没了下文swift

刚好这几天好友出了几道有关GCD的题目,运行结果出于意料,仔细摸索后,发现苹果基于libdispatch作了一些有趣的修改工做,因而想将这两道题目分享出来。因为朋友提供的运行代码为Swift书写,在此我转换成等效的OC代码进行讲述。你若是了解了下面两个概念,会让后续的阅读更加容易:api

  • 同步与异步的概念
  • 队列与线程的区别

被误解的概念

对于主线程和主队列,咱们可能会有这么一个理解多线程

主线程只会执行主队列的任务。一样,主队列只会在主线程上被执行异步

主线程只会执行主队列的任务

首先是主线程只会执行主队列的任务。在iOS中,只有主线程才拥有权限向渲染服务提交打包的图层树信息,完成图形的显示工做。而咱们在work queue中提交的UI更新老是无效的,甚至致使崩溃发生。而因为主队列只有一条,其余的队列所有都是work queue,所以能够得出主线程只会执行主队列的任务这一结论。可是,有下面这么一段代码:async

dispatch_queue_t mainQueue = dispatch_get_main_queue();
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_queue_set_specific(mainQueue, "key", "main", NULL);
dispatch_sync(globalQueue, ^{
    BOOL res1 = [NSThread isMainThread];
    BOOL res2 = dispatch_get_specific("key") != NULL;
    
    NSLog(@"is main thread: %zd --- is main queue: %zd", res1, res2);
});
复制代码

根据正常逻辑的理解来讲,这里的两个判断结果应该都是NO,但运行后,第一个判断为YES,后者为NO,输出说明了主线程此时执行了work queue的任务函数

dispatch_sync

上面的代码在换成async以后就会获得预期的判断结果,但在同步执行的状况下就会致使这个问题。在查找缘由以前,借用bestswifter文章中的代码一用,首先sync的调用栈以及大体源码以下:oop

dispatch_sync  
    └──dispatch_sync_f
        └──_dispatch_sync_f2
            └──_dispatch_sync_f_slow


static void _dispatch_sync_f_slow(dispatch_queue_t dq, void *ctxt, dispatch_function_t func) {  
    _dispatch_thread_semaphore_t sema = _dispatch_get_thread_semaphore();
    struct dispatch_sync_slow_s {
        DISPATCH_CONTINUATION_HEADER(sync_slow);
    } dss = {
        .do_vtable = (void*)DISPATCH_OBJ_SYNC_SLOW_BIT,
        .dc_ctxt = (void*)sema,
    };
    _dispatch_queue_push(dq, (void *)&dss);

    _dispatch_thread_semaphore_wait(sema);
    _dispatch_put_thread_semaphore(sema);
    // ...
}
复制代码

能够看到对于libdispatch对于同步任务的处理是采用sema信号量的方式堵塞调用线程直到任务被处理完成,这也是为何sync嵌套使用是一个死锁问题。根据源码能够获得执行的流程图:性能

image

但实际运行后,block是执行在主线程上的,代码真正流程是这样的:学习

image

所以能够作一个猜测:

因为sync函数自己会堵塞当前执行线程直到任务执行。为了减小线程切换的开销,以及避免线程被堵塞的资源浪费,因而对sync函数进行了改进:在大多数状况下,直接在当前线程执行同步任务

既然有了猜测,就须要验证。之因此说是大多数状况,是由于目前主队列只在主线程上被执行仍是有效的,所以咱们排除global -sync-> main这种条件。所以为了验证效果,须要建立一个串行线程:

dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_sync(globalQueue, ^{
    BOOL res1 = [NSThread isMainThread];
    BOOL res2 = dispatch_get_specific("key") != NULL;
    
    NSLog(@"is main thread: %zd --- is main queue: %zd", res1, res2);
});

dispatch_async(globalQueue, ^{
    NSThread *globalThread = [NSThread currentThread];
    dispatch_sync(serialQueue, ^{
        BOOL res = [NSThread currentThread] == globalThread;
        NSLog(@"is same thread: %zd", res);
    });
});
复制代码

运行后,两次判断的结果都是YES,结果足以验证猜测,能够肯定苹果为了提升性能,已经对sync作了修改。另外global -sync-> main测试结果发现sync的调用过程不会被优化

主队列只会在主线程上执行

上面说过,只有主线程才有权限提交渲染任务。一样的,出于下面两个设定,这个理解应当是成立的:

  • 主队列老是能够调用UIKit的接口api
  • 同时只有一条线程可以执行串行队列的任务

一样的,朋友给出了另外一份代码:

dispatch_queue_set_specific(mainQueue, "key", "main", NULL);

dispatch_block_t log = ^{
    printf("main thread: %zd", [NSThread isMainThread]);
    void *value = dispatch_get_specific("key");
    printf("main queue: %zd", value != NULL);
}

dispatch_async(globalQueue, ^{
    dispatch_async(dispatch_get_main_queue(), log);
});

dispatch_main();
复制代码

运行以后,输出结果分别为NOYES,也就是说此时主队列的任务并无在主线程上执行。要弄清楚这个问题的缘由显然难度要比上一个问题难度大得多,由于若是子线程能够执行主队列的任务,那么此时是没法提交打包图层信息到渲染服务的

一样的,咱们能够先猜想缘由。不一样于正常的项目启动代码,这个Swift文件的运行更像是脚本运行,由于缺乏了一段启动代码:

@autoreleasepool
{
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
复制代码

为了找到答案,首先须要对问题主线程只会执行主队列的任务的代码进行改造一下。另外因为第二个问题涉及到执行任务所在的线程mach_thread_self函数会返回当前线程的id,能够用来判断两个线程是否相同:

thread_t threadId = mach_thread_self();

dispatch_queue_t mainQueue = dispatch_get_main_queue();
dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);

dispatch_async(globalQueue, ^{
    dispatch_async(mainQueue, ^{
        NSLog(@"%zd --- %zd", threadId == mach_thread_self(), [NSThread isMainThread]);
    });
});

@autoreleasepool
{
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
复制代码

这段代码的运行结果都是YES,说明在UIApplicationMain函数先后主队列任务执行的线程id是相同的,所以能够得出两个条件:

  • 主队列的任务老是在同一个线程上执行
  • UIApplicationMain函数调用后,isMainThread返回了正确结果

结合这两个条件,能够作出猜测:在UIApplicationMain中存在某个操做使得本来执行主队列任务的线程变成了主线程,其猜测图以下:

因为UIApplicationMain是个私有api,咱们没有其实现代码,可是咱们都知道在这个函数调用以后,主线程的runloop会被启动,那么这个线程的变更是否是跟runloop的启动有关呢?为了验证这个判断,在手动启动runloop定时的去检测线程:

dispatch_block_t log = ^{
    printf("is main thread: %zd\n", [NSThread isMainThread]);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), log);
}

dispatch_async(globalQueue, ^{
    dispatch_async(dispatch_get_main_queue(), log);
});

[[NSRunLoop currentRunLoop] run];
复制代码

runloop启动后,全部的检测结果都是YES

// console log
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
"is main thread: 1"
复制代码

代码的运行结果验证了这个猜测,但结论就变成了:

thread -> runloop -> main thread

这样的结论,随便启动一个work queuerunloop就能轻易的推翻这个结论,那么是否可能只有第一次启动runloop的线程才有可能变成主线程?为了验证这个猜测,继续改造代码:

dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);

dispatch_block_t logSerial = ^{
    printf("is main thread: %zd\n", [NSThread isMainThread]);
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, log);
}

dispatch_async(serialQueue, ^{
    [[NSRunLoop currentRunLoop] run];
});
dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
});

dispatch_main();
复制代码

在保证了子线程的runloop是第一个被启动的状况下,全部运行的输出结果都是NO,也就是说由于runloop修改了线程的priority的猜测是不成立的,那么基于UIApplicationMain测试代码的两个条件没法解释主队列为何没有运行在主线程上

主队列不老是在同一个线程上执行

通过来回推敲,我发现主队列老是在同一个线程上执行这个条件限制了进一步扩大猜测的可能性,为了验证这个条件,经过定时输出主队列任务所在的threadId来检测这个条件是否成立:

thread_t threadId = mach_thread_self();
dispatch_queue_t serialQueue = dispatch_queue_create("serial.queue", DISPATCH_QUEUE_SERIAL);
printf("current thread id is: %d\n", threadId);

dispatch_block_t logMain = ^{
    printf("=====main queue======> thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), logMain);
}

dispatch_block_t logSerial = ^{
    printf("serial queue thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, logSerial);
}

dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
    dispatch_async(dispatch_get_main_queue(), logMain);
});

dispatch_main();
复制代码

在测试代码中增长子队列定时作对比,发现无论是serial queue仍是main queue,都有可能运行在不一样的线程上面。可是若是去掉了子队列做为对比,main queue只会执行在一条线程上,但该线程的threadId老是不等同于咱们保存下来的数值:

// console log
current thread id is: 775
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 7171"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 6403"
"serial queue thread id is: 4355"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 4355"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 4355"
"=====main queue======> thread id is: 4355"
"serial queue thread id is: 6403"
"serial queue thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"=====main queue======> thread id is: 6403"
"serial queue thread id is: 1547"
"serial queue thread id is: 6403"
"=====main queue======> thread id is: 1547"
"serial queue thread id is: 1547"
复制代码

发现了这一个新的现象后,结合以前的信息来看,能够得出一个新的猜测:

有一个专用启动线程用于启动主线程的runloop,启动前主队列会被这个线程执行

要测试这个猜测也很简单,只要对比runloop先后的threadId是否一致就能够了:

thread_t threadId = mach_thread_self();
printf("current thread id is: %d\n", threadId);

dispatch_block_t logMain = ^{
    printf("=====main queue======> thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), logMain);
}

dispatch_block_t logSerial = ^{
    printf("serial queue thread id is: %d\n", mach_thread_self());
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), serialQueue, logSerial);
}

dispatch_async(globalQueue, ^{
    dispatch_async(serialQueue, logSerial);
    dispatch_async(dispatch_get_main_queue(), logMain);
});

[[NSRunLoop currentRunLoop] run];

// console log
current thread id is: 775
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
"=====main queue======> thread id is: 775"
复制代码

运行结果说明了并不存在什么启动线程,一旦runloop启动后,主队列就会一直执行在同一个线程上,而这个线程就是主线程。因为runloop自己是一个不断循环处理事件的死循环,这才是它启动后主队列一直运行在一个主线程上的缘由。最后为了测试启动runloop对串行队列的影响,单独启动子队列和一块儿启动后,发现另外一个现象:

  • 主队列的runloop一旦启动,就只会被该线程执行任务
  • 子队列的runloop没法绑定队列和线程的执行关系

因为在源码中async调用对于主队列和子队列的表现不一样,后者会直接启用一个线程来执行子队列的任务,这就是致使了runloop在主队列和子队列上差别化的缘由,也能说明苹果并无大肆修改libdispatch的源码。

有趣的runloop唤醒机制

若是你看过runloop相关的博客或者文档,那么应该会它是一个不断处理消息、事件的死循环,但死循环是会消耗大量的cpu资源的(自旋锁就是死循环空转)。runloop为了提升线程的使用效率以及减小没必要要的损耗,在没有事件处理的时候,假如此时存在timer、port、source任一一种,那么进入休眠状态;假如不存在三者其中之一,那么runloop将会退出

所以为了探讨runloop的唤醒,咱们能够经过添加一个空端口来维持runloop的运转:

CFRunLoopRef runloop = NULL;
NSThread *thread = [[NSThread alloc] initWithBlock: ^{
    runloop = [NSRunLoop currentRunLoop].getCFRunLoop;
    [[NSRunLoop currentRunLoop] addPort: [NSMachPort new] forMode: NSRunLoopCommonModes];
    [[NSRunLoop currentRunLoop] run];
}];
复制代码

这里主要讨论的是仓鼠大佬的第五题,原问题能够直接到最下面翻连接。主要要说明的是问题中提到的两个api,用于添加任务到这个runloop中:

CFRunLoopPerformBlock(runloop, NSRunLoopCommonModes, ^{
    NSLog(@"runloop perform block 1");
});

[NSObject performSelector: @selector(log) onThread: thread withObject: obj waitUntilDone: NO];

CFRunLoopPerformBlock(runloop, NSRunLoopCommonModes, ^{
    NSLog(@"runloop perform block 2");
});
复制代码

上面的代码若是去掉了第二个perform调用,那么第一个调用不会输出,反之就会都输出。从名字上看,两个调用都是往所在的线程里面添加执行任务,区别在于后者的调用实际上并非直接插入任务block,而是将任务包装成一个timer事件来添加,这个事件会唤醒runloop。固然,前提是runloop处在休眠中。

CFRunLoopPerformBlock提供了往runloop中添加任务的功能,但又不会唤醒runloop,在事件不多的状况下,这个api能有效的减小线程状态切换的开销

其余

过了一个漫长的春节假期以后,感受急需一个节假日来休息,惋惜这只是奢望。因为节后综合征,在这周从新返工的状态感受通常,也偶尔会提不起神来,但愿本身尽快恢复过来。另外随着不断的积累,一些自觉得熟悉的奇怪问题又总能带来新的认知和收获,我想这就是学习最大的快乐了

关于使用代码

因为Swift语法上和OC始终存在差别,第二段代码并不能很好的还原,若是对此感兴趣的朋友能够关注下方仓鼠大佬的博客连接,大佬放话后续会放出源码。另外若是不想阅读libdispatch源码又想对这部分的逻辑有所了解的朋友能够看下面的连接文章

扩展阅读

仓鼠大佬

深刻了解GCD

关注个人公众号获取更新信息
相关文章
相关标签/搜索