奇怪的GCD

jzbb7438 6年前
   <p>多线程一直是我相当感兴趣的技术知识之一,个人尤其喜爱 GCD 这个轻量级的多线程解决方案,为了了解其实现,不厌其烦的翻阅 libdispatch 的源码。甚至因为太喜欢了,本来想要写这相应的源码解析系列文章,但害怕写的不好,于是除了开篇的类型介绍,也是草草了事,没了下文</p>    <p>恰好这几天好友出了几道有关 GCD 的题目,运行结果出于意料,仔细摸索后,发现苹果基于 libdispatch 做了一些有趣的修改工作,于是想将这两道题目分享出来。由于朋友提供的运行代码为 Swift 书写,在此我转换成等效的 OC 代码进行讲述。你如果了解了下面两个概念,会让后续的阅读更加容易:</p>    <ul>     <li>同步与异步的概念</li>     <li>队列与线程的区别</li>    </ul>    <h2>被误解的概念</h2>    <p>对于主线程和主队列,我们可能会有这么一个理解</p>    <p>主线程只会执行主队列的任务。同样,主队列只会在主线程上被执行</p>    <h3>主线程只会执行主队列的任务</h3>    <p>首先是主线程只会执行主队列的任务。在 iOS 中,只有主线程才拥有权限向渲染服务提交打包的图层树信息,完成图形的显示工作。而我们在 work queue 中提交的 UI 更新总是无效的,甚至导致崩溃发生。而由于主队列只有一条,其他的队列全部都是 work queue ,因此可以得出 主线程只会执行主队列的任务 这一结论。但是,有下面这么一段代码:</p>    <pre>  <code class="language-objectivec">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);  });</code></pre>    <p>根据正常逻辑的理解来说,这里的两个判断结果应该都是 NO ,但运行后,第一个判断为 YES ,后者为 NO ,输出说明了主线程此时执行了 work queue 的任务</p>    <p>dispatch_sync</p>    <p>上面的代码在换成 async 之后就会得到预期的判断结果,但在同步执行的情况下就会导致这个问题。在查找原因之前,借用 bestswifter 文章中的代码一用,首先 sync 的调用栈以及大致源码如下:</p>    <pre>  <code class="language-objectivec">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);      // ...  }</code></pre>    <p>可以看到对于 libdispatch 对于同步任务的处理是采用 sema 信号量的方式堵塞调用线程直到任务被处理完成,这也是为什么 sync 嵌套使用是一个死锁问题。根据源码可以得到执行的流程图:</p>    <p><img src="https://simg.open-open.com/show/2b0a6edb230781dc21a99fade730124f.jpg"></p>    <p>但实际运行后, block 是执行在主线程上的,代码真正流程是这样的:</p>    <p><img src="https://simg.open-open.com/show/79f3c1564642772bf674e8016c2dfb51.jpg"></p>    <p>因此可以做一个猜想:</p>    <p>由于 sync 函数本身会堵塞当前执行线程直到任务执行。为了减少线程切换的开销,以及避免线程被堵塞的资源浪费,于是对 sync 函数进行了改进:在大多数情况下,直接在当前线程执行同步任务</p>    <p>既然有了猜想,就需要验证。之所以说是大多数情况,是因为目前 主队列只在主线程上被执行 还是有效的,因此我们排除 global -sync-> main 这种条件。因此为了验证效果,需要创建一个串行线程:</p>    <pre>  <code class="language-objectivec">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);      });  });</code></pre>    <p>运行后,两次判断的结果都是 YES ,结果足以验证猜想,可以确定苹果为了提高性能,已经对 sync 做了修改。另外 global -sync-> main 测试结果发现 sync 的调用过程不会被优化</p>    <h3>主队列只会在主线程上执行</h3>    <p>上面说过,只有主线程才有权限提交渲染任务。同样的,出于下面两个设定,这个理解应当是成立的:</p>    <ul>     <li>主队列总是可以调用 UIKit 的接口 api</li>     <li>同时只有一条线程能够执行串行队列的任务</li>    </ul>    <p>同样的,朋友给出了另一份代码,但由于代码中存在一个 Swift 的关键函数,因此直接展示原代码:</p>    <pre>  <code class="language-objectivec">let key = DispatchSpecificKey<String>()  DispatchQueue.main.setSpecific(key: key, value: "main")    func log() {      debugPrint("main thread: \(Thread.isMainThread)")      let value = DispatchQueue.getSpecific(key: key)      debugPrint("main queue: \(value != nil)")  }    DispatchQueue.global().async {      DispatchQueue.main.async(execute: log)  }    dispatchMain()</code></pre>    <p>运行之后,输出结果分别为 NO 和 YES ,也就是说此时主队列的任务并没有在主线程上执行。要弄清楚这个问题的原因显然难度要比上一个问题难度大得多,因为如果子线程可以执行主队列的任务,那么此时是无法提交打包图层信息到渲染服务的</p>    <p><img src="https://simg.open-open.com/show/831f9363ba1a90b0a700641cbaa8083f.jpg"></p>    <p>同样的,我们可以先猜测原因。不同于正常的项目启动代码,这个 Swift 文件的运行更像是脚本运行,因为缺少了一段启动代码:</p>    <pre>  <code class="language-objectivec">@autoreleasepool  {      return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));  }</code></pre>    <p>为了找到答案,首先需要对问题 主线程只会执行主队列的任务 的代码进行改造一下。另外由于第二个问题涉及到 执行任务所在的线程 , mach_thread_self 函数会返回当前线程的 id ,可以用来判断两个线程是否相同:</p>    <pre>  <code class="language-objectivec">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]));  }</code></pre>    <p>这段代码的运行结果都是 YES ,说明在 UIApplicationMain 函数前后主队列任务执行的线程 id 是相同的,因此可以得出两个条件:</p>    <ul>     <li>主队列的任务总是在同一个线程上执行</li>     <li>在 UIApplicationMain 函数调用后, isMainThread 返回了正确结果</li>    </ul>    <p>结合这两个条件,可以做出猜想:在 UIApplicationMain 中存在某个操作使得原本执行主队列任务的线程变成了 主线程 ,其猜想图如下:</p>    <p><img src="https://simg.open-open.com/show/afcc183960f16fe8983983b888501804.jpg"></p>    <p>由于 UIApplicationMain 是个私有 api ,我们没有其实现代码,但是我们都知道在这个函数调用之后,主线程的 runloop 会被启动,那么这个线程的变动是不是跟 runloop 的启动有关呢?为了验证这个判断,在手动启动 runloop 定时的去检测线程:</p>    <pre>  <code class="language-objectivec">func log() {      debugPrint("is main thread: \(Thread.isMainThread)")  DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: log)  }    DispatchQueue.global().async {      DispatchQueue.main.async(execute: log)  }    RunLoop.current.run()</code></pre>    <p>在 runloop 启动后,所有的检测结果都是 YES :</p>    <pre>  <code class="language-objectivec">// console log  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"  "is main thread: true"</code></pre>    <p>代码的运行结果验证了这个猜想,但结论就变成了:</p>    <p>thread -> runloop -> main thread</p>    <p>这样的结论,随便启动一个 work queue 的 runloop 就能轻易的推翻这个结论,那么是否可能只有第一次启动 runloop 的线程才有可能变成主线程?为了验证这个猜想,继续改造代码:</p>    <pre>  <code class="language-objectivec">let serialQueue = DispatchQueue(label: "serial.queue")    func logSerial() {      debugPrint("is main thread: \(Thread.isMainThread)")  serialQueue.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logSerial)  }    serialQueue.async {      RunLoop.current.run()  }    DispatchQueue.global().async {      serialQueue.async(execute: logSerial)  }    dispatchMain()</code></pre>    <p>在保证了子线程的 runloop 是第一个被启动的情况下,所有运行的输出结果都是 false ,也就是说因为 runloop 修改了线程的 priority 的猜想是不成立的,那么基于 UIApplicationMain 测试代码的两个条件无法解释 主队列为什么没有运行在主线程上</p>    <p>主队列不总是在同一个线程上执行</p>    <p>经过来回推敲,我发现 主队列总是在同一个线程上执行 这个条件限制了进一步扩大猜想的可能性,为了验证这个条件,通过定时输出主队列任务所在的 threadId 来检测这个条件是否成立:</p>    <pre>  <code class="language-objectivec">let threadId =  mach_thread_self()  let serialQueue = DispatchQueue(label: "serial.queue")  debugPrint("current thread id is: \(threadId)")    func logMain() {      debugPrint("=====main queue======> thread id is: \(mach_thread_self())")            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logMain)  }    func logSerial() {      debugPrint("serial queue thread id is: \(mach_thread_self())")      serialQueue.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logSerial)  }    DispatchQueue.global().async {      serialQueue.async(execute: logSerial)      DispatchQueue.main.async(execute: logMain)  }    dispatchMain()</code></pre>    <p>在测试代码中增加子队列定时做对比,发现不管是 serial queue 还是 main queue ,都有可能运行在不同的线程上面。但是如果去掉了子队列作为对比, main queue 只会执行在一条线程上,但该线程的 threadId 总是不等同于我们保存下来的数值:</p>    <pre>  <code class="language-objectivec">// 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"</code></pre>    <p>发现了这一个新的现象后,结合之前的信息来看,可以得出一个新的猜想:</p>    <p>有一个专用启动线程用于启动主线程的 runloop ,启动前主队列会被这个线程执行</p>    <p>要测试这个猜想也很简单,只要对比 runloop 前后的 threadId 是否一致就可以了:</p>    <pre>  <code class="language-objectivec">let threadId =  mach_thread_self()  debugPrint("current thread id is: \(threadId)")    func logMain() {      debugPrint("=====main queue======> thread id is: \(mach_thread_self())")            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: logMain)  }    DispatchQueue.global().async {      DispatchQueue.main.async(execute: logMain)  }    RunLoop.current.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"</code></pre>    <p>运行结果说明了并不存在什么 启动线程 ,一旦 runloop 启动后,主队列就会一直执行在同一个线程上,而这个线程就是主线程。由于 runloop 本身是一个不断循环处理事件的死循环,这才是它启动后主队列一直运行在一个主线程上的原因。最后为了测试启动 runloop 对串行队列的影响,单独启动子队列和一起启动后,发现另一个现象:</p>    <ul>     <li>主队列的 runloop 一旦启动,就只会被该线程执行任务</li>     <li>子队列的 runloop 无法绑定队列和线程的执行关系</li>    </ul>    <p>由于在源码中 async 调用对于主队列和子队列的表现不同,后者会直接启用一个线程来执行子队列的任务,这就是导致了 runloop 在主队列和子队列上差异化的原因,也能说明苹果并没有大肆修改 libdispatch 的源码。</p>    <h2>其他</h2>    <p>过了一个漫长的春节假期之后,感觉急需一个节假日来休息,可惜这只是奢望。由于节后综合征,在这周重新返工的状态感觉一般,也偶尔会提不起神来,希望自己尽快恢复过来。另外随着不断的积累,一些自以为熟悉的奇怪问题又总能带来新的认知和收获,我想这就是学习最大的快乐了</p>    <h2>关于使用代码</h2>    <p>由于 Swift 语法上和 OC 始终存在差异,第二段代码并不能很好的还原,如果对此感兴趣的朋友可以关注下方 仓鼠大佬 的博客链接,大佬放话后续会放出源码。另外如果不想阅读 libdispatch 源码又想对这部分的逻辑有所了解的朋友可以看下面的链接文章</p>    <h2>扩展阅读</h2>    <p><a href="/misc/goto?guid=4959757292718071072" rel="nofollow,noindex">仓鼠大佬</a></p>    <p><a href="/misc/goto?guid=4959757292804225823" rel="nofollow,noindex">深入了解GCD</a></p>    <p> </p>    <p>来自:http://sindrilin.com/note/2018/03/03/weird_thread/</p>    <p> </p>