起因
昨天其他部门的同事突然反馈一起相对来说比较严重的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的数组:
- 遍历的时候,对信号量
dq
enter了5次,简单理解信号量减去5次。 - SDWebImage下载回调的时候,对信号量
dq
leave了5次,于是信号量增加了5次。 - 执行完毕,整个group执行完成。
但是,由于SDWebImage的下载是异步且无法保证时间的,如果在整个group没有执行完毕期间,上述函数整体又被执行到了,会怎么样?
我们再用上述的例子来走遍流程。
- 第一次遍历,我们创建了信号量
dq1
,enter了5次,dq1 现在 = -5。 - SDWebImage的下载回调捕捉了
dq1
,准备留待回调后加回来,我们将这次遍历生成的下载回调block统称为b10, b12, b13, b14, b15。 - 但是,在第一次SDWebImage下载回调还没执行的时候,第二次函数遍历来了。
- 第二次遍历,我们创建了信号量
dq2
,enter了5次,dq2 现在 = -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分支。
最后
看起来很简单、清晰易懂的代码,没想到也会造成巨大的问题。所以,写代码一定要谨慎谨慎再谨慎。