iOS疑难问题排查之深入探究dispatch_group crash

起因

昨天其他部门的同事突然反馈一起相对来说比较严重的Crash问题(占比达到了yyyy左右,并且从Crash堆栈上可以发现很多情况下是一启动就Crash了)。去掉隐私数据大致堆栈如下:

Thread 0 Crashed:
0   libdispatch.dylib               0x000000018953e828 _dispatch_group_leave :76 (in libdispatch.dylib)
1   libdispatch.dylib               0x000000018954b084 __dispatch_barrier_sync_f_slow_invoke :320 (in libdispatch.dylib)
2   libdispatch.dylib               0x000000018953a1bc __dispatch_client_callout :16 (in libdispatch.dylib)
3   libdispatch.dylib               0x000000018953ed68 __dispatch_main_queue_callback_4CF :1000 (in libdispatch.dylib)
4   CoreFoundation                  0x000000018a65e810 ___CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ :12 (in CoreFoundation)
5   CoreFoundation                  0x000000018a65c3fc ___CFRunLoopRun :1660 (in CoreFoundation)
6   CoreFoundation                  0x000000018a58a2b8 _CFRunLoopRunSpecific :444 (in CoreFoundation)
7   GraphicsServices                0x000000018c03e198 _GSEventRunModal :180 (in GraphicsServices)
8   UIKit                           0x00000001905d17fc -[UIApplication _run] :684 (in UIKit)
9   UIKit                           0x00000001905cc534 _UIApplicationMain :208 (in UIKit)
10  xxxiPhone                       0x0000000100041a98 main main.m:26 (in xxxiPhone)
11  libdyld.dylib                   0x000000018956d5b8 _start :4 (in libdyld.dylib)

一看到这种堆栈,头就大了,除了Thread 0 的第10行是和程序本身二进制相关的堆栈,其余的调用栈全部是系统库里面的,并且唯一一行程序本身二进制的代码还是一个完全没作用的main函数。

好吧,只能重新找找其余的线索。从堆栈上来反推当时的场景应该是如下场景:

启动 -> main函数 -> main_queue 执行 -> dispatch_group_leave -> Crash

于是,我们的线索就从最后的_dispatch_group_leave来进行。

首先先来最简单的方法:下符号断点:dispatch_group_leave

当然事情没有这么简单,尝试重复多次也没有断到我们想要的符号断点上,于是这条路暂时考虑放弃(结合Crash率也可以发现这并非必现的Crash场景)。

这条路不通,我们先尝试全局搜索dispatch_group_leave,结果发现有如下几条线索:

  • 外部开源库
  • 自身工程代码

结合Crash出现的版本以及以上上述各库最后升级时间来判断,我们基本确定出在问题出现在自身工程中的代码里,如下:

dispatch_group_t serviceGroup = dispatch_group_create();
dispatch_group_notify(serviceGroup, dispatch_get_main_queue(), ^{
    NSLog(@"ttttttt:%@",t);
});

// t 是一个包含一堆字符串的数组 
[t enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    dispatch_group_enter(serviceGroup);
    SDWebImageCompletionWithFinishedBlock completion =
    ^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
        dispatch_group_leave(serviceGroup);
        NSLog(@"idx:%zd",idx);
    };
    [[SDWebImageManager sharedManager] downloadImageWithURL:[NSURL URLWithString:t[idx]]
                                                    options:SDWebImageLowPriority
                                                   progress:nil
                                                  completed:completion];
}];

这段代码逻辑非常简单吧:给你一个数组,里面是一堆图片地址。你使用多线程进行并发下载,直到所有图片都下载完成(可以失败)进行回调,其中图片下载使用的是SDWebImage

这段代码里面的的确确出现了可疑的dispatch_group_leave,但是这段代码太常见了。和同事认认真真检查了许久,同时也和天猫、手淘中使用dispatch_group_t的地方进行了对比,没发现任何问题。

好吧,问题一下子陷入了僵局,只好上终极调试大法:汇编分析法

通过文章开头的堆栈我们查找libdispatch.dylib中对应的Crash位置,然后通过汇编解析查看相关指令,结果如下:

从上图看出,指令挂掉的原因是因为执行了brk (brk可以理解为跳转指令特殊的一种,一旦执行,就会进入某种Exception模式,导致Crash)。

为什么执行dispatch_group_leave会挂?从上述图中汇编不难发现,dispatch_group_leave具有两条分支:比较x9寄存器和0之间的关系,如果是less equal,就跳转到0x180502808(即会crash的逻辑分支);反之则正确执行ret返回。

那么x9寄存器是什么?我们继续往上看指令ldxr x9, [x10],x9中的值是以x10寄存器中的内容作为地址,取64位放入x9寄存器中。继续,那么x10中的内存是什么?x10中的内容是指令add x10, x0, #0x30。也就是x10 = x0 + 48(0x30的10进制表示)。那么,函数调用的时候x0是self,也即是一个类或者结构体的首地址。所以这两句指令加起来的含义就是取结构体地址偏移48位置的某个成员变量的值。

除此之外,汇编解析还完整保留了Crash的字符串提示: “BUG IN CLIENT OF LIBDISPATCH: Unbalanced call to dispatch_group_leave()”

结合这两点,我们查看libdispatch的源码,代码如下:

void
dispatch_group_leave(dispatch_group_t dg)
{
    dispatch_semaphore_t dsema = (dispatch_semaphore_t)dg;
    dispatch_atomic_release_barrier();
    long value = dispatch_atomic_inc2o(dsema, dsema_value);
    if (slowpath(value == LONG_MIN)) {
        DISPATCH_CLIENT_CRASH("Unbalanced call to dispatch_group_leave()");
    }
    if (slowpath(value == dsema->dsema_orig)) {
        (void)_dispatch_group_wake(dsema);
    }
}

注:苹果开发的libdispatch源码经过了各种变形修改,不是真正运行的代码,仅供参考。

果不其然,这段代码完整复现了我们之前汇编分析的结果:如果dg信号量中的字段dsema_value原子性自加一后等于LONGMIN,就会CRASH。为什么会Crash呢?

我们需要关注下LONG_MIN这个数字,LONG_MIN = -LONG_MAX - 1。理解起来很简单,就是可以表征的(该类型合法范围)最大数和最小数。

搜索下LONGMAX,我们发现在dispatch_group_create里面发现了它的踪影:

dispatch_group_t
dispatch_group_create(void)
{
    dispatch_group_t dg = _dispatch_alloc(DISPATCH_VTABLE(group),
            sizeof(struct dispatch_semaphore_s));
    _dispatch_semaphore_init(LONG_MAX, dg);
    return dg;
}

好了, 这下豁然开朗。这两段代码的结合告诉了我们一个事实:当dq这个信号量加一导致溢出后,dispatch_group_leave就会Crash。

最简单的复现代码如下:

- (void)viewDidLoad 
{
    [super viewDidLoad];
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_leave(group);
    // Do any additional setup after loading the view, typically from a nib.
}

当然,上述代码相当直白简单,我们一般都不会犯这样低级的错误。

代码究竟出错在哪?

了解了dispatch_group_leave的出错原因后,我们再回到我们刚刚认为没问题的代码,一定是哪个地方我们欠考虑了。

上述代码执行流程还是非常简单的,我们用模型简述一遍:

遍历数组,对每个URL进行dispatch_group_enter,然后将其丢入一个下载block交由SDWebImage进行并发下载,下载回调(无论失败或者成功)后执行dispatch_group_leave

我们举个简单的例子,假设我们有一个包含5个URL的数组:

  1. 遍历的时候,对信号量dq enter了5次,简单理解信号量减去5次。
  2. SDWebImage下载回调的时候,对信号量dq leave了5次,于是信号量增加了5次。
  3. 执行完毕,整个group执行完成。

但是,由于SDWebImage的下载是异步且无法保证时间的,如果在整个group没有执行完毕期间,上述函数整体又被执行到了,会怎么样?

我们再用上述的例子来走遍流程。

  1. 第一次遍历,我们创建了信号量dq1,enter了5次,dq1 现在 = -5。
  2. SDWebImage的下载回调捕捉了dq1,准备留待回调后加回来,我们将这次遍历生成的下载回调block统称为b10, b12, b13, b14, b15。
  3. 但是,在第一次SDWebImage下载回调还没执行的时候,第二次函数遍历来了。
  4. 第二次遍历,我们创建了信号量dq2,enter了5次,dq2 现在 = -5。
  5. 创建第二次遍历对应的回调block,称为b20,b21, b22, b23, b24。

通过查阅SDWebImageDownloader.m源码我们发现:

dispatch_barrier_sync(self.barrierQueue, ^{
    SDWebImageDownloaderOperation *operation = self.URLOperations[url];
    if (!operation) {
    operation = createCallback();

    // !!!!!!!特别注意这行!!!!!!!!!
    self.URLOperations[url] = operation;

    __weak SDWebImageDownloaderOperation *woperation = operation;
    operation.completionBlock = ^{
      SDWebImageDownloaderOperation *soperation = woperation;
      if (!soperation) return;
      if (self.URLOperations[url] == soperation) {
          [self.URLOperations removeObjectForKey:url];
      };
    };
}

SDWebImage的下载器会根据URL做下载任务对应NSOperation映射,也即之前创建的下载回调Block。

好,就是这行导致Crash的发生。为什么呢?

我们设想下,假设在第二次遍历中包含了第一次遍历中的图片URL,比如b20对应的图片URL和b10对应的图片URL一样,那么在SDWebImage的处理回调里,b20就会替换掉b10。于是,在第一次遍历创建的5个下载任务回调中,b10回调的时候实际已经执行的是b20,也就是dq2 + 1;而在后续第二次遍历执行下载任务回调的时候,又分别执行了b20-b24的5个任务,导致dq2 + 5。这从导致dq2实际上leave的次数比enter的次数多了1 (6比5),导致了dq2信号量的数值溢出,从而进入了Crash分支。

最后

看起来很简单、清晰易懂的代码,没想到也会造成巨大的问题。所以,写代码一定要谨慎谨慎再谨慎。