最近再次遇到多线程读写导致的crash 问题,写了一个测试demo,记录分析过程。
for (int i = 0; i < 10000; i++)
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
self.object = [TestObject new];
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
self.object = [TestObject new];
});
}
上面是暴力重现多线程读写的崩溃,在debug环境下,开启zombie ,窗口会输出:
message sent to deallocated instance 0x170200c50
上面用了10000次碰撞才触发崩溃,日常debug 环境下很难出现。但是到了线上环境,用户量一大,问题就出现了。然后,我们只能通过崩溃日志查找崩溃。
下面截取有用的崩溃日志部分:
Incident Identifier: A22F5FFF-F98D-4F3B-95C3-45790E61F049
CrashReporter Key: 33c3939d695bcfab6c9a16efca18399fae8a83c3
Hardware Model: iPhone6,2
Process: Crash_mulThread [716]
Path: /private/var/containers/Bundle/Application/7CCB0B27-4B51-4D77-B571-A49153C8E8B7/Crash_mulThread.app/Crash_mulThread
Identifier: vedon.Crash-mulThread
Version: 1 (1.0)
Code Type: ARM-64 (Native)
Role: Foreground
Parent Process: launchd [1]
Coalition: vedon.Crash-mulThread [1266]
Date/Time: 2017-05-05 23:58:50.9184 +0800
Launch Time: 2017-05-05 23:58:50.6346 +0800
OS Version: iPhone OS 10.2.1 (14D27)
Report Version: 104
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x00000003a42abec8
Termination Signal: Segmentation fault: 11
Termination Reason: Namespace SIGNAL, Code 0xb
Terminating Process: exc handler [0]
Triggered by Thread: 4
Filtered syslog:
None found
Thread 4 name: Dispatch queue: com.apple.root.default-qos
Thread 4 Crashed:
0 libobjc.A.dylib 0x0000000184f48894 objc_class::demangledName(bool) + 28
1 libobjc.A.dylib 0x0000000184f5bc04 objc_object::overrelease_error() + 24
2 libobjc.A.dylib 0x0000000184f5bc04 objc_object::overrelease_error() + 24
3 Crash_mulThread 0x00000001000e03f4 -[ViewController setObject:] (ViewController.m:13)
4 Crash_mulThread 0x00000001000e0148 __29-[ViewController viewDidLoad]_block_invoke (ViewController.m:29)
5 libdispatch.dylib 0x00000001853921fc _dispatch_call_block_and_release + 24
6 libdispatch.dylib 0x00000001853921bc _dispatch_client_callout + 16
7 libdispatch.dylib 0x00000001853a0a4c _dispatch_queue_override_invoke + 732
8 libdispatch.dylib 0x00000001853a234c _dispatch_root_queue_drain + 572
9 libdispatch.dylib 0x00000001853a20ac _dispatch_worker_thread3 + 124
10 libsystem_pthread.dylib 0x000000018559b2a0 _pthread_wqthread + 1288
11 libsystem_pthread.dylib 0x000000018559ad8c start_wqthread + 4
Thread 4 crashed with ARM Thread State (64-bit):
x0: 0x00000003a42abea8 x1: 0x0000000000000000 x2: 0x000000017401a1f0 x3: 0x000000017401a200
x4: 0x00000001700f0e00 x5: 0x0000000000000000 x6: 0x0000000000000000 x7: 0x0000000000000000
x8: 0xbaddf653a42abead x9: 0x000009a1000e5665 x10: 0xffffe9a1000e5665 x11: 0x000000330000007f
x12: 0x000000010101e110 x13: 0x000005a1000e554d x14: 0x00000001a597a340 x15: 0x0000000000397c01
x16: 0x0000000184f580f4 x17: 0x00000001000e03b4 x18: 0x0000000000000000 x19: 0x0000000170019be0
x20: 0x00000003a42abea8 x21: 0x0000000000000000 x22: 0x0000000000000000 x23: 0x00000001aa54d200
x24: 0x000000016e1bf0e0 x25: 0x00000001abc326c0 x26: 0x0000000000000014 x27: 0x0000000000000004
x28: 0xffffffffffffffff fp: 0x000000016e1bed10 lr: 0x0000000184f5bc04
sp: 0x000000016e1becd0 pc: 0x0000000184f48894 cpsr: 0x80000000
Binary Images:
0x1000d8000 - 0x1000e3fff Crash_mulThread arm64 /var/containers/Bundle/Application/7CCB0B27-4B51-4D77-B571-A49153C8E8B7/Crash_mulThread.app/Crash_mulThread
0x1001dc000 - 0x10020bfff dyld arm64 /usr/lib/dyld
0x184ebc000 - 0x184ebdfff libSystem.B.dylib arm64 <1b4d75209f4a37969a9575de48d48668> /usr/lib/libSystem.B.dylib
0x184ebe000 - 0x184f13fff libc++.1.dylib arm64 /usr/lib/libc++.1.dylib
0x184f14000 - 0x184f34fff libc++abi.dylib arm64 /usr/lib/libc++abi.dylib
0x184f38000 - 0x185311fff libobjc.A.dylib arm64 <538f809dcd7c35ceb59d99802248f045> /usr/lib/libobjc.A.dylib
SIGSEGV 访问了非法的地址(地址还没有从系统映射到当前进程的内存空间), 一般是野指针导致, 而野指针一般由于多线程操作对象导致.
SIGABRT 一般是Exception或者其他的代码主动退出的问题.
SIGTRAP 代码里面触发了调试指令, 该指令可能由编译器提供的trap方法触发, 如'__builtin_trap()'
SIGBUS 一般由于地址对齐问题导致, 单纯的OC代码挺难触发的, 主要是系统库方法或者其他c实现的方法导致
SIGILL 表示执行了非法的cpu指令, 但是一般是由于死循环导致
通过崩溃日志,定位到崩溃的点在:
0 libobjc.A.dylib 0x0000000184f48894 objc_class::demangledName(bool) + 28
1 libobjc.A.dylib 0x0000000184f5bc04 objc_object::overrelease_error() + 24
2 libobjc.A.dylib 0x0000000184f5bc04 objc_object::overrelease_error() + 24
3 Crash_mulThread 0x00000001000e03f4 -[ViewController setObject:] (ViewController.m:13)
每条崩溃堆栈的记录称为frame ,每个frame 都有一个编号,它是当前frame 在整个调用栈的索引。看到frame 3 是demo代码调用的地方,当前pc 地址** 0x0000000184f48894** 对应frame 0 调用地址,而其他的frame 都是历史记录,不会保存当前frame所有寄存器的值,只存了lr 寄存器的内容(FYI: lr 是方法调用完之后,要返回的地址)。
从frame 2 就可以知道,对象被over release 了。实际情况一般是:丢失重要的堆栈信息。下面纯粹是在只有frame 3 的堆栈下,怎么定位问题。
frame 3 ,只有一个 setObject:也就是: self.object = [TestObject new]; 咋一看,不怎么可能崩溃。下面来分析一下:
可以看到堆栈地址是: 0x00000001000e03f4,程序加载到内存的地址在0x1000d8000 - 0x1000e3fff 之间。
通过计算 0x00000001000e03f4 - 0x1000d8000 = 0x83F4。
0x83F4 为相对偏移,这时候使用hopper 看看在0x83F4 究竟是什么。
Screen Shot 2017-05-06 at 12.40.14 AM.png
frame 3 的lr 寄存器保存了调用方法的下一个指令地址,那么可以确定崩溃发生在:imp___stubs__objc_storeStrong,下面分析一下这段汇编做了什么。
00000001000083b4 sub sp, sp, #0x30 ; Objective C Implementation defined at 0x10000c478 (instance method), DATA XREF=0x10000c478
00000001000083b8 stp x29, x30, [sp, #0x20]
00000001000083bc add x29, sp, #0x20
// 保存方法调用的现场
00000001000083c0 adrp x8, #0x10000d000
00000001000083c4 add x8, x8, #0x538 ; _OBJC_IVAR_$_ViewController._object
// 动态定位获取ViewController._object的描述地址, 放入x8
00000001000083c8 stur x0, [x29, #-0x8]
00000001000083cc str x1, [sp, #0x10]
00000001000083d0 str x2, [sp, #0x8]
// 把参数self/selector/传进来的TestObject对象, 存到栈里
00000001000083d4 ldr x0, [sp, #0x8]
00000001000083d8 ldur x1, [x29, #-0x8]
00000001000083dc ldrsw x8, x8
00000001000083e0 add x8, x1, x8
// 从x8里把_object的在ViewController对象的偏移量取出来, 并与x1相加, 也就是`self指针+偏移量`, 结果存在x8 里面
00000001000083e4 str x0, sp
// 把传进来的对象存入栈
00000001000083e8 mov x0, x8
// 把`self指针+偏移量`指针放入x0
00000001000083ec ldr x1, sp
// 把传进来的对象从栈里取出来放到x1
00000001000083f0 bl imp___stubs__objc_storeStrong
// 把x1里传进来的对象赋值给x0, 然后强引用一次
00000001000083f4 ldp x29, x30, [sp, #0x20]
00000001000083f8 add sp, sp, #0x30
// 恢复最前面保存的现场
00000001000083fc ret
// 返回
上面其实就是一段setter 的代码,崩溃发生在imp___stubs__objc_storeStrong,通过查看苹果开源代码:objc_storeStrong
void
objc_storeStrong(id *location, id obj)
{
id prev = *location;
if (obj == prev) {
return;
}
objc_retain(obj);
*location = obj;
objc_release(prev);
}
objc_storeStrong 并不是原子性操作,当线程A可能执行到*location = obj 时,另外一个线程B执行 prev = *location; 。那么当线程A继续执行到objc_release(prev); 线程B 继续执行 ,跑到objc_release(prev), 此刻,prev已经被释放过了。Crash ~~~
========================================
iOS多线程同时操作同一内存造成野指针,原因:崩溃线程崩中使用指针的真正创建与销毁地方在另另外一个线程中,崩溃线程只是使用这个指针拷贝。
这两个操作发送在两个线程中。
问题总结:
对于野指针问题,当问题根源找到时觉得问题比较轻松,但未找到前的排查真正做起来很累,更多是考验定位人员的心理素质和分析能力,总结的一些经验如下:
1、需要从崩溃点上层各个调用对象作为中介从来源到去处引起共用指针的,要细心、耐心,把来源和去处一层层追根朔源才能发现问题。
2、要善于分析日志文件中提供的信息,当第一次崩溃是由于日志输出等级低信息量不足并且不能定位与解决该问题时候需要将日志输出等级调高并加入一些辅助定位的输出信息,在下一次崩溃时候输出的日志信息将提供很大帮助。
3、解决野指针问题通过阅读代码很重要,只有这样才能对出问题时候程序线程运行的数量、运行功能和时序以及变量调用有清楚和全面的认识。
4、编写程序时候尽量少用指针拷贝,如果不得以使用,编写代码一定要具备要有多线程运行意识,从根源上杜绝野指针的出现。
iOS多线程同时操作同一内存造成野指针,一个解决方案。
什么是多线程的野指针问题
之前在《浅谈多线程编程误区》一文中,曾经举过如下这样的多线程setter例子:
for (int i = 0; i < 10000; i++) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
self.data = [[NSMutableData alloc] init];
});
}
如果这个self.data是个nonatomic的属性的话,就会造成在多次释放导致的野指针问题。(具体可以见《浅谈多线程编程误区》的原理解释)。
从原理解释中不难发现,本质上会产生野指针的场景是由于我们没有对临界区进行保护。导致赋值替换的操作不是原子性的。
有些人会说,例子中你刻意构建了一万个线程才会导致Crash。而我们平时就用用几个线程,不会有问题的。
理论上一万个线程只不过是把两个线程中可能出现问题的概率放大了而已。在一万个线程中会出现的多线程野指针问题在两个线程中一定也会发生。
传统业界方案:赋值加锁
既然原子性是导致野指针的罪魁祸首,那么我们只要在对应可能产生冲突的临界区内加锁就好了,比如:
[lock lock];
self.data = [[NSMutableData alloc] init];
[lock unlock]
按照这样的做法,同一时间不管有多少线程试图对self.data进行赋值,最终都只有一个线程能够抢到锁对其赋值。
但是这样的做法从安全性角度来说是解决了原子赋值的问题。但是这样的做法却对开发要求比较严格,因为任意非基础类型的对象(Int, Bool)都有可能产生多线程赋值的野指针,所以开发需要牢记自身的属性变量究竟有哪些会在多线程场景中被使用到。
而且,这样的方案还有一个非常大的不确定性!
当你开发了一个底层SDK,对外暴露了一些公共的readwrite的Property。别人对你的property赋值的时候,你怎么确定他们一定会做到线程安全?
我的方案:runtime追踪对象初始化的GCD Queue
我们都知道,在Objective-C中,对于一个property的赋值最终都会转化成对于ivar的setter方法。所以,如果我们能确保setter方法的线程安全性,就能确保多线程赋值不会产生野指针。
好,按照这个思路进行操作的话,我们大致需要如下几个步骤:
获取第一次setter调用的时机及对应的线程。
将这个线程记录下来。
后续调用setter的时候,判断当前setter调用的线程是不是我们之前记录的线程,如果是,直接赋值。如果不是,派发到对应的线程进行调用。
获取所有的setter,重复实现上述步骤。
看起来思路很简单,具体实现起来却有一定的难度,容我由浅入深慢慢道来:
获取第一次赋值的线程并记录
由于我们不能通过成员变量就记录每个ivar对应的setter的初始化线程(这样setter的个数就无限增长了),因此本质上我们只有通过局部静态变量的方式来作为存储。同时由于我们只需要在初次执行时进行记录,所以很理所当然就想到了dispatch_once。
具体代码如下:
static dispatch_queue_t initQueue;
static void* initQueueKey;
static void* initQueueContext;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// 1. 主队列
if ([UIApplication isMainQueue]) {
initQueue = dispatch_get_main_queue();
initQueueKey = [UIApplication mainQueueKey];
initQueueContext = [UIApplication mainQueueContext];
} else {
// 2. 非主队列
const char *label = [NSStringFromSelector(_cmd) UTF8String];
initQueueKey = &initQueueKey;
initQueueContext = &initQueueContext;
initQueue = dispatch_queue_create(label, nil);
dispatch_queue_set_specific(initQueue, initQueueKey, initQueueContext, nil);
}
});
从代码中不难发现,由于主队列是全局共用的,所以如果这次setter的赋值是在主队列进行的,那么就直接复用主队列即可;而如果当前的队列我们自身都不确定的话,那么就干脆开辟一个串行的队列用语这个setter的后续赋值,并将其记录下来。
细心的读者可能会发现,我们标题里写的是线程,但是在代码中记录的却是GCD的队列(Queue)。而且,我们判断的是主队列而不是主线程。这是为什么呢?
嘿嘿,容我卖个关子,文章最后会有详细的阐述。
判断后续赋值是否是记录的线程
由于我们之前记录的是队列,所以我们是无法直接使用诸如如下代码的方式进行是否是同一个线程的判断
[NSThread currentThread] == xxxThread
在iOS7之前,苹果提供了dispatch_get_current_queue()用于获取当前正在执行的队列,如果有这个方法,我们就可以很容易判断这个队列和我们记录的队列是否是同一个了。但是很不幸的是,该方法已经被从GCD的Public API中移除了,一时间研究陷入了僵局。
不过好在libdispatch是开源的,经过一段时间的摸索,我发现了这个方法dispatch_get_specific,其自身实现如下:
DISPATCH_NOINLINE
void *
dispatch_get_specific(const void *key)
{
if (slowpath(!key)) {
return NULL;
}
void *ctxt = NULL;
// 1. 获取当前线程的执行队列
dispatch_queue_t dq = _dispatch_queue_get_current();
while (slowpath(dq)) {
// 2. 如果进行过标记
if (slowpath(dq->dq_specific_q)) {
ctxt = (void *)key;
dispatch_sync_f(dq->dq_specific_q, &ctxt,
_dispatch_queue_get_specific);
if (ctxt) break;
}
// 3. 向上传递至target Queue
dq = dq->do_targetq;
}
return ctxt;
}
通过上述代码不难理解,系统会自动获取当前线程正在执行的队列的。如果进行该队列进行过标记,就根据我们传入的key去获取key对应的value(ctxt)。如果查询到了,就返回。否则按照目标队列层层上查,直至root_queue也没找到为止。(关于libdispatch的具体原理,我下周还会专门写篇细细分析的文章)。
通过这个方法,我们可以在直接记录初始化队列的时候对其进行特殊的标定:
dispatch_queue_set_specific(initQueue, initQueueKey, initQueueContext, nil);
随后在后续setter执行的时候通过如下代码进行判断并进行相应的直接赋值或者队列重新派发:
// 如果是当前队列
if (dispatch_get_specific(initQueueKey) == initQueueContext) {
_threadSafeArray = threadSafeArray;
} else {
// 不是当前队列
dispatch_sync(initQueue, ^{
_threadSafeArray = threadSafeArray;
});
}
由于我们的目的是减轻其他开发的负担,所以不得不借助了runtime的Method Swizzling技术。但是传统的Method Swizzling技术是将函数实现两两交换。如果按照这个思路,我们就需要为每一个setter编写一个对应的hook_setter,这工作量无疑太巨大了。
所以,在这里我们需要的一个中心重定向的过程:即,将所有的setter都转移到一个hook_proxy中。代码如下:
(void)hookAllPropertiesSetter
{
unsigned int outCount;
objc_property_t *properties = class_copyPropertyList([self class], &outCount);
NSMutableArray *readWriteProperties = [[NSMutableArray alloc] initWithCapacity:outCount];
for (unsigned int i = 0; i < outCount; i++) {
objc_property_t property = properties[i];
NSString *propertyName = [[NSString alloc] initWithCString:property_getName(property) encoding:NSUTF8StringEncoding];
unsigned int attrCount;
objc_property_attribute_t *attrs = property_copyAttributeList(property, &attrCount);
// !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
// !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
BOOL isReadOnlyProperty = NO;
for (unsigned int j = 0; j < attrCount; j++) {
if (attrs[j].name[0] == 'R') {
isReadOnlyProperty = YES;
break;
}
}
free(attrs);
if (!isReadOnlyProperty) {
[readWriteProperties addObject:propertyName];
}
}
free(properties);
for (NSString *propertyName in readWriteProperties) {
NSString *setterName = [NSString stringWithFormat:@"set%@%@:", [propertyName substringToIndex:1].uppercaseString, [propertyName substringFromIndex:1]];
// !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
// !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
NSString *hookSetterName = [NSString stringWithFormat:@"hook_set%@:", propertyName];
SEL originSetter = NSSelectorFromString(setterName);
SEL newSetter = NSSelectorFromString(hookSetterName);
swizzleMethod([self class], originSetter, newSetter);
}
}
在这里有两点需要注意的地方:
readonly的property是不具备setter功能的,所以将其过滤。
将每个setter,比如setThreadSafeArray都swizzle成了hook__setThreadSafeArray。即为每一个setter都定制了一个对应的hook_setter。
哎,有人会问,你刚刚不才说为每一个setter编写对应的hook_setter是费时费力的吗?怎么自己打自己脸啊?
别急,容我慢慢道来。
在Method Swizzling的时候,我们需要调用class_getInstanceMethod来进行对应方法名的函数查找。整个过程简述如下:
method cache list -> method list -> 动态方法决议 -> 方法转交 (forward Invocation)
其中,在动态方法决议这步,如果我们添加了之前的没找到的方法,那么整个查找过程又会重新开始一遍。
由于那些hook_setter是压根不会存在于method list中的,所以在查找这些函数的时候,一定会走到动态决议这一步。
基于此,我实现了如下的动态决议函数:
(BOOL)resolveInstanceMethod:(SEL)sel
{
NSString *selName = NSStringFromSelector(sel);
if ([selName hasPrefix:@“hook_”]) {
Method proxyMethod = class_getInstanceMethod([self class], @selector(hook_proxy:));
class_addMethod([self class], sel, method_getImplementation(proxyMethod), method_getTypeEncoding(proxyMethod));
return YES;
}
return [super resolveInstanceMethod:sel];
}
从代码中很容易发现,如果是之前那么hook_setter的函数名,我就讲这些方法的函数实现全部重定向到函数hook__proxy上。
寻找上下文
在传统的Method Swizzling技术中,由于我们是两两交换,因此我们不需要上下文这一个步骤,直接调用hook_setter就可以重新返回对应的原setter方法。
可是在本文的实现中,由于我们将所有的setter都重定向到了hook__proxy中,所以我们需要在hook_proxy中寻找究竟是给哪个property赋值。
如果对Method Swizzling的理解只停留在表面,是很难想到后续步骤的。
Method Swizzling的原理是只是交换IMP,即函数实现。而我们在Objective-C的函数调用统统是通过objc_msgSend结合函数的Selector(可以简单理解为函数名)来找到真正的函数实现。
因此,swizzle后的Selector没变,变的是IMP。
有了这个理解,我们就可以在hook_proxy使用__cmd这个隐藏变量,它会指引我们究竟是哪个Setter当前正在被调用,具体代码如下:
(void)hook_proxy:(NSObject *)proxyObject
{
// 只是实现被换了,但是selector还是没变
NSString *originSelector = NSStringFromSelector(_cmd);
NSString *propertyName = [[originSelector stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@":"]] stringByReplacingOccurrencesOfString:@“set” withString:@""];
if (propertyName.length <= 0) return;
NSString *ivarName = [NSString stringWithFormat:@"_%@%@", [propertyName substringToIndex:1].lowercaseString, [propertyName substringFromIndex:1]];
//NSLog(@“hook_proxy is %@ for property %@”, proxyObject, propertyName);
重复之前步骤即可。
}
其他拓展
本文中只是探索了下没有重载setter的那些ivar,因此只需要简单对ivar进行赋值即可。
如果你碰到了大量自定义setter的ivar,那么也一样很简单,你只需要维护一个ivar 到对应自定义的setter的imp映射,在hook_proxy将setValue:ForKey:替换成直接的IMP调用即可。
一些额外细节
线程和GCD Queue并不是一一对应的关系。
前面提到了,我们要记录的是队列而不是线程。相信很多人可能一开始都不能理解,那么我用如下这样的代码进行解释:
if ([NSThread isMainThread]) {
[self doSomeThing];
} else {
dispatch_sync(dispatch_get_main_queue(), ^{
[self doSomething];
});
}
上述代码想必大家非常熟悉,就是全包在主线程执行一些操作,比如UI操作等等。但是事实上,这里有个误区:
主队列一定在主线程执行,而主线程不一定只执行主队列。
换句话说:上述代码的if 和 else是不等价的。
有时候,主线程有可能会被调度到执行其他队列(其他线程亦是如此),比如如下代码:
// 在主线程创建
dispatch_queue_t dq = dispatch_queue_create(‘com.mingyi.dashuaibi’, NULL);
dispatch_sync(dq, ^{
NSLog(@“current thread is %@”, [NSThread currentThread]);
});
具体效果,大家可以自己尝试下,看看Log输出的结果是不是主线程。
为什么不能直接将所有的setter直接hook到hook_proxy,非要通过动态决议来进行。
我们举个简单的例子,假设我们有两个property,分别叫A和B。那么在执行下述代码的时候:
for (int i = 0; i < 2; i++) {
SEL originSetter = NSSelectorFromString(setterName);
SEL newSetter = NSSelectorFromString(hook_proxy);
swizzleMethod([self class], originSetter, newSetter);
}
第一次交换的时候,Setter A的 IMP和 hook_proxy的 IMP进行了交换,这一步没问题。
第二次交换的时候,Setter B的 IMP和 hook_proxy的 IMP进行了交换,而此时hook_proxy的IMP已经指向了Setter A的IMP,因此导致的结果就是交换错乱了,调用setter B实质上是调用了setter A。