一般来说,多线程编程因具有以下几个优点,一直被广泛应用:
- 资源利用率更好
- 程序设计在某些情况下更简单
- 程序响应更快
但是因为多线程而导致的crash问题,也是令程序员非常头疼的一个问题,因为线程调度执行顺序的不确定性,造成了crash一般都是小概率出现,在开发测试阶段很难发现,而一旦上线面对用户,造成的影响却是不容小觑的。
一、Crash的场景
有一种特别常见的会造成crash的场景为:多线程读写可变数组/字典
我们来看几个简单的测试代码,看看到底在什么情况下会引起Crash:
// 1. 多线程只读数据
NSMutableDictionary * dict1 = [[NSMutableDictionary alloc] init];
[dict1 setObject:@"test0" forKey:@"test0"];
[dict1 setObject:@"test1" forKey:@"test1"];
[dict1 setObject:@"test2" forKey:@"test2"];
for (int i = 0; i <1000; i++) {dispatch_async(dispatch_get_global_queue(0, 0), ^{NSString * key = [NSString stringWithFormat:@"test%d", i%3];NSString * value = dict1[key];NSLog(@"%@:%@", key, value);});
}// 2. 多线程只写数据
// 字典仅有一个key
// 多线程写同一个key
NSMutableDictionary * dict2 = [[NSMutableDictionary alloc] init];
for (int i = 0; i <1000; i++) {dispatch_async(dispatch_get_global_queue(0, 0), ^{[dict2 setObject:@(i) forKey:@"test"];});
}// 3. 多线程只写数据
// 字典有多个key
// 多线程同时写不同key
NSMutableDictionary * dict3 = [[NSMutableDictionary alloc] init];
for (int i = 0; i <1000; i++) {dispatch_async(dispatch_get_global_queue(0, 0), ^{[dict3 setObject:[NSString stringWithFormat:@"test%d", i] forKey:[NSString stringWithFormat:@"test%d", i]];});
}// 4. 多线程只写数据
// 字典有多个key
// 多线程随机写key
NSMutableDictionary * dict4 = [[NSMutableDictionary alloc] init];
for (int i = 0; i <1000; i++) {int x = arc4random() % 3;dispatch_async(dispatch_get_global_queue(0, 0), ^{[dict4 setObject:[NSString stringWithFormat:@"test%d", i] forKey:[NSString stringWithFormat:@"test%d", x]];});
}// 5. 多线程读写数据
NSMutableDictionary * dict5 = [[NSMutableDictionary alloc] init];
for (int i = 0; i <1000; i++) {dispatch_async(dispatch_get_global_queue(0, 0), ^{[dict5 setObject:[NSString stringWithFormat:@"test%d", i%3] forKey:[NSString stringWithFormat:@"test%d", i%3]];});dispatch_async(dispatch_get_global_queue(0, 0), ^{NSString * key = [NSString stringWithFormat:@"test%d", i%3];NSString * value = dict5[key];NSLog(@"%@:%@", key, value);});
}
注:可适当将测试次数加大,以增加复现概率。
测试结果发现:
- 多线程只读数据情况下:不会发生Crash;
- 多线程只写数据,字典仅有一个key,且多线程读写同一个key的情况下,会发生Crash;
- 多线程只写数据,字典有多个key,且多线程同时写不同key的情况下,会发生Crash;
- 多线程只写数据,字典有多个key,多线程随机写key的情况下,会发生Crash;
- 多线程读写数据的情况下,会发生Crash。
其中:
2 - 4 情况下的崩溃堆栈,均含有关键字:-[__NSDictionaryM setObject:forKey:]
5情况下,则为:-[__NSDictionaryM objectForKeyedSubscript:]
除此之外,还有其他可能的堆栈表现形式:
0 libsystem_kernel.dylib __pthread_fchdir
1 libsystem_c.dylib abort
2 libsystem_malloc.dylib free
3 CoreFoundation _mdict_rehashd
4 CoreFoundation -[__NSDictionaryM setObject:forKey:]
如果在多线程读写时进行了遍历操作,则Crash堆栈则可能为:
0 CoreFoundation ___exceptionPreprocess
1 libobjc.A.dylib objc_exception_throw
2 CoreFoundation _CFArgv
3 CoreFoundation -[__NSPlaceholderArray initWithObjects:count:]
4 CoreFoundation +[NSArray arrayWithObjects:count:]
5 CoreFoundation -[NSDictionary allKeys]
可以看到只有多线程只读的情况下,不会引起Crash,其他情况下都会,包括有:
二、Crash解决方案
那如何规避多线程下操作可变数组/字典的Crash问题呢?一般来说,保证多线程安全的常用方法主要有以下几种:
- @property使用atomic属性
- @synchronized(token)
- NSLock
- GCD
1. @property使用atomic属性
在这个情况下,atomic属性会为变量生成原子操作的getter和setter方法,听上去这样就可以解决了多线程的读写Crash问题,事实真是如此吗?我们来看下面这个例子:
@property (atomic, strong) NSArray* arr;dispatch_async(dispatch_get_global_queue(0, 0), ^{//thread Afor (int i = 0; i <100000; i ++) {if (i % 2 == 0) {self.arr = @[@"1", @"2", @"3"];} else {self.arr = @[@"1"];}NSLog(@"Thread A: %@\n", self.arr);}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{//thread Bfor (int i = 0; i <100000; i ++) {if (self.arr.count >= 2) {NSString * str = [self.arr objectAtIndex:1];}NSLog(@"Thread B: %@\n", self.arr);}
});
即使B线程在访问objectAtIndex之前做了count的判断,依旧发生了Crash:
#0 __pthread_kill
#1 pthread_kill
#2 abort
#3 abort_message
#4 default_terminate_handler()
#5 _objc_terminate()
#6 std::__terminate(void (*)())
#7 __cxa_throw
#8 objc_exception_throw
#9 -[__NSArrayI objectAtIndex:]
原因也是由于前后两行代码之间arr所指向的内存区域被其他线程修改了。
所以在大部分多线程情况下,即使声明为atomic也没有用,atomic的作用只是给getter和setter加了个锁,保证代码进入getter或者setter函数内部时是安全的,一旦出了getter和setter,多线程安全只能靠程序员自己保障了,在需要做多线程安全的场景,自己去额外加锁做同步。
2. @synchronized(token) & NSLock
还是如上的例子,我们加上@synchronized看看Crash情况如何:
dispatch_async(dispatch_get_global_queue(0, 0), ^{@synchronized(self) {//thread Afor (int i = 0; i <100000; i ++) {if (i % 2 == 0) {self.arr = @[@"1", @"2", @"3"];} else {self.arr = @[@"1"];}NSLog(@"Thread A: %@\n", self.arr);}}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{@synchronized(self) {//thread Bfor (int i = 0; i <100000; i ++) {if (self.arr.count >= 2) {NSString * str = [self.arr objectAtIndex:1];}NSLog(@"Thread B: %@\n", self.arr);}}
});
经过测试发现,Crash没有了!
同样使用NSLock效果一样,可以看到这两种方法是行之有效的,但是这就足够了吗?
3. GCD
GCD(Grand Central Dispatch),这个是苹果为多核的并行运算提出的解决方案,会自动合理地利用更多的CPU内核(比如双核、四核),最重要的是它会自动管理线程的生命周期(创建线程、调度任务、销毁线程),开发者只需要告诉 GCD 想要如何执行什么任务,不需要编写任何线程管理代码。
同样如上的例子,我们使用GCD后,看看使用效果:
dispatch_queue_t serialQueue = dispatch_queue_create("serialQueue", DISPATCH_QUEUE_SERIAL);
dispatch_async(dispatch_get_global_queue(0, 0), ^{//thread Afor (int i = 0; i <100000; i ++) {dispatch_async(serialQueue, ^{if (i % 2 == 0) {self.arr = @[@"1", @"2", @"3"];} else {self.arr = @[@"1"];}NSLog(@"Thread A: %@\n", self.arr);});}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{//thread Bfor (int i = 0; i <100000; i ++) {dispatch_async(serialQueue, ^{if (self.arr.count >= 2) {NSString * str = [self.arr objectAtIndex:1];}NSLog(@"Thread B: %@\n", self.arr);});}
});
同样的,Crash没有了!
4. 对比
由上,我们有3种有效的保证多线程读写安全的方法,那么应该如何选择呢?
- @synchronized(token) 使用简单,可读性强,但是性能消耗大
- NSLock性能消耗小于@synchronized(token) ,但使用不当,可能引起死锁
- GCD性能最优,且在复杂代码逻辑下的多线程读写时,可有效保障所需代码的串行执行,避免逻辑出错。
以本人所开发的业务某段逻辑的效率真实对比:
可以看到使用dispatch_sync/dispatch_async相比于@synchronized,性能提升10+倍
5. 最终方案
核心思想为:保证同一数组/字典的操作按顺序执行
写方法使用dispatch_async,读方法使用dispatch_sync,例如:
_ioQueue = dispatch_queue_create("ioQueue", DISPATCH_QUEUE_SERIAL);- (void)setSafeObject:(id)object forKey:(NSString *)key {key = [key copy];dispatch_async(self.ioQueue, ^{if (key && object) {[_dic setObject:object forKey:key];}});
}- (id)getSafeObjectForKey:(NSString *)key {__block id result = nil;dispatch_sync(self.ioQueue, ^{result = [_dic objectForKey:key];});return result;
}
如果想进一步提升读写效率,可考虑只一个线程写,但允许多线程读:
_ioQueue = dispatch_queue_create("ioQueue", DISPATCH_QUEUE_CONCURRENT);- (void)setSafeObject:(id)object forKey:(NSString *)key {key = [key copy];// 会等待barrier之前的block执行完成后才执行dispatch_barrier_async(self.ioQueue, ^{if (key && object) {[_dic setObject:object forKey:key];}});
}- (id)getSafeObjectForKey:(NSString *)key {__block id result = nil;dispatch_sync(self.ioQueue, ^{result = [_dic objectForKey:key];});return result;
}
笔者目前使用更多的是第一种写法,使用串行队列,因为使用起来更不易出错,且目前性能消耗已经非常低,已满足要求,后续待业务版本功能稳定后,会考虑切换到第二个写法,真实感受是否第二种写法效率更加好。