iOS八股文(十二)GCD之函数和死锁源码浅析

2,342 阅读3分钟

书接上回,我们了解了dispatch源码中串行队列和并行队列的区别。本文准备对同步函数和异步函数源码浅析。GCD关于iOS开发的部分,准备用三遍文章。下一遍文章,准备讲解下GCD中其他函数的使用包括调度组、信号量等。

面试题

还是一样,先看两道面试题。

第一题
//经典面试题
- (void)interview04 {
    //全局队列
    dispatch_queue_t globQueue = dispatch_get_global_queue(0, 0);
    __block int a = 0;
    while (a < 100) {
        dispatch_async(globQueue, ^{
//            NSLog(@"内部: %d  - %@",a,[NSThread currentThread]);
            a++;
        });
    };
    NSLog(@"外部打印_____ %d",a);
}

问最后的打印结果是多少。

其实这道题是我真实在面试过程中遇到的的,当时面试的while条件是a<5,我当时第一次遇到还是比较懵,一直在纠结是不是5,思考片刻也没有答上来。

首先结果只有3个,等于100大于100大于等于100。等于100的情况肯定是有可能发生的。所以就看有没有可能发生大于100的情况。代码走出while循环的时候a=100,就看在NSLog打印的时候还回不回异步执行a++了。再看a++的操作是在其他线程异步完成的(异步函数+全局队列),也就是说,有可能来到了下一次循环的while判断,上一次循环的a++还没有执行。所以a++执行的次数应该是大于等于100的。

image.png

第二题

第二道题是第一道题的变种:

- (void)interview05 {
    dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
    __block int a = 0;
    for (int i = 0; i < 100; i++) {
        dispatch_async(globalQueue, ^{
            a++;
        });
    }
    NSLog(@"外部答应_____ %d",a);
}

这题是a++只有100次,就看在NSlog执行的时候这100个a++是否都执行完了。同样是异步执行,极限情况下是有可能执行完成的,这样打印的就刚好是100,如果有没有执行完的a++,那么打印出来的值有可能小于100.

image.png

死锁的底层原理

死锁crash分析

上文讲过使用同步函数往当前串行队列添加任务会产生死锁。我们先debug出crash的堆栈调用关系,然后找到对应dispatch源码中的位置,进行分析:

image.png

image.png 可以看到如果满足其条件就会触发crash。再看条件中函数的实现:

image.png

image.png 这里可以着重分析下这段代码和这一句注释。先说下我最终得到的结论,这里其实是判断lock_value 和tid 的第2位到第31位(后30位)是否相等,先贴2个定义:

image.png _dispatch_lock_owner(lock_value)的操作其实是lock_value后2位抹0处理。 ^ 为异或,相同为0,不同为1,如果两者高30位相同,结果为高30位为0,而后面与运算0xfffffffc,其实是忽略二者后2位的比较结果。

其中tidthread ID线程编号,而lock_value是通过队列获取的。获取代码如下:

image.png 这里比较难懂,但其实源码阅读到这里已经对其中原理有一定的认知了。

dispatch_sync + 串行队列的时候,这个串行队列就会对应一个线程,如果添加任务的代码执行的线程,和串行队列所对应的线程是一个线程的时候,就会发生死锁,从而crash。

死锁示例

例如:

- (void)interview033 {
    dispatch_queue_t queue = dispatch_queue_create("com.osDemo.serial", DISPATCH_QUEUE_SERIAL);
    dispatch_sync(queue, ^{
        NSLog(@"---任务1");
        dispatch_sync(dispatch_get_main_queue(), ^{
            NSLog(@"---任务2");
        });
        NSLog(@"---任务3");
        
    });
}

这段代码是会发生死锁的,main_queue对应的线程是主线程,而dispatch_sync(dispatch_get_main_queue()执行也是在主线程执行的,所以发生死锁。

-(void)interview011{
    dispatch_queue_t queue = dispatch_queue_create("com.demo.queue", DISPATCH_QUEUE_SERIAL);
    NSLog(@"test____1"); // 任务1
    dispatch_async(queue, ^{
        NSLog(@"test____2"); // 任务2
        dispatch_sync(queue, ^{
            NSLog(@"test____3"); // 任务3
        });
        NSLog(@"test____4"); // 任务4
    });
    usleep(20);
    NSLog(@"test____5"); // 任务5
}

这段代码同样会发生死锁,dispatch_sync(queue执行的是在一个自线程中,而queue此时对应的线程也是该线程,所以死锁。

同步函数源码分析

找到源码中同步函数的实现:

image.png 其中unlikely为小概率事件,我们看他执行原理的时候,先跟这大概率的分支去分析。按照这个思路,我们一层一层往里找。

image.png 注意这里,调用了_dispatch_barrier_sync_f这个从名字看,最终调用了栅栏函数。我们继续看栅栏函数实现,这里的参数func就是我们外面的block任务

image.png 这里func直接传入到离其他方法内部,继续往里面点:

image.png 直接看和func有关的函数:

image.png 走过千山万水,终于来到了最我们block的执行的地方,这里可以看到block是直接执行了,所以遇到同步函数,我们可以粗暴的理解为,里面的任务马上就要执行。

也可以在block内部打断点,通过lldb bt命令,查看调用堆栈,同样可以找到同步函数的调用关系:

image.png

image.png

这里就有疑问了,为什么会调用barrier函数?
如果是并发队列,岂不是会执行完之前的任务,才会执行当前任务么?

又对如下代码进行了打印测试:

image.png 这时候发现并没有走barrier函数:

image.png

这里再次回到barrier调用的源码部分:

image.png 可以看到barrier调用的条件是dq_width == 1,上文我们也了解到,只有串行队列的dq_width才为1,故如果是串行队列走上面的分支,如果是并发队列走下面的分支。

从调试代码中,我们可以把同步函数+并发队列这种情况,理解为在并发队列中进行插队。并不会等之前的任务执行完成,再执行这个同步任务,而是优先执行同步任务。 但在串行队列中,同步函数相同于栅栏函数,会等待队列中之前的任务完成之后再执行当前任务。可以理解为串行队列不允许有插队行为。

异步函数的源码分析

在dispatch源码中找到定义: image.png 点进去: image.png 继续看有关work的操作: image.png 这里是将外面传入的block进行了封存。

image.png 注意这里的dx_push,在宏定义中找到其定义

image.png

image.png 这里是根据不同种类的队列,执行不同的函数。(读到这感觉平时写的if else switch 有点low)。

继续查找了这些方法,发现也没有对block的调用相关的代码。只能通过lldb的方式来查看block的调用堆栈了。

image.png 发现跟同步函数一样,都是通过_dispatch_client_callout触发的。而在这之前是有很多的线程操作。

可以对异步函数的特点总结:

  • 将任务存储,不立即执行
  • 有开辟线程的能力