虽然很多程序员可以对异步、GCD等等与线程相关的概念说的天花乱坠。但是实质上深挖本质的话,大多数人并不能很好的区分Race Condition,Atomic,Immutable对象在线程安全中真正起到的作用。
所以今天就以这篇文章来谈谈我所理解的线程安全。
首先就允许我从Immutable来开始整篇话题吧。
Immutable
最近几年,Immutable这个说法开始越来越流行。比如用过Swift的人都知道,Swift相较于Objective-C有一个比较明显的改动就是将结构体(Struct)和类型(Class)进行了分离。从某种方面来说,Swift将值类型和引用类型进行了明显的区分。为什么要这么做?
- 避免了引用类型在被作为参数传递后被他人持有后修改,从而引发比较难以排查的问题。
- 在某些程度上提供了一定的线程安全(因为多线程本身的问题很大程序上出在写修改的不确定性)。而Immutable 数据的好处在于一旦创建结束就无法修改,因此相当于任一一个线程在使用它的过程中仅仅是使用了读的功能。
7.19补充:
- 其实之前说这段只是想引出Objective-C下文中提到的问题,Swift中Immutable数据结构是线程安全的,这是毫无疑问的。
- 对于Swift这种从语言层面就开始设计“值类型”,即Immutable的语言来说,比如let a = [1, 2, 3, 4, 5],这个a一旦被赋值过一次,就无法再次被赋值了,这是真正的immutable。但是对于Objective-C这门语言来说:比如我们NSArray *a = [NSArray arrayWithObjects:@"明弈大帅比", nil]; 这个a所指向的对象的的确确是个immutable的对象,但是这个a本身,还是可以被多次重新赋值。
所以我想说的是,虽然Immutable这个概念在Objective-C中也存在,但是在OC使用Immutable不直接等同于线程安全,不然在使用NSArray,NSDictionary等等Immutable对象之后,为啥还会有那么多奇怪的bug出现?
指针与对象
有些朋友会问,Immutable都将一个对象变为不可变的“固态”了,为什么还是不安全呢,在各个线程间传递的只是一份只读文件啊。
是的,对于一个Immutable的对象来说,它自身是不可变了。但是在我们的程序里,我们总是需要有“东西”去指向我们的对象的吧,那这个“东西”是什么?指向对象的指针。
指针想必大家都不会陌生。对于指针来说,其实它本质也是一种对象,我们更改指针的指向的时候,实质上就是对于指针的一种赋值。所以想象这样一种场景,当你用一个指针指向一个Immutable对象的时候,在多线程更改的时候,你觉得你的指针修改是线程安全的吗?这也就是为什么有些人碰到一些跟NSArray这种Immutable对象的在多线程出现奇怪bug的时候会显得一脸懵逼。
举例:
// Thread A 其中immutableArrayA count 7
self.xxx = self.immutableArrayA;
// Thread B 其中immutableArrayB count 4
self.xxx = self.immutableArrayB
// main Thread
[self.xxx objectAtIndex:5]
上述这个代码片段,绝对是存在线程的安全的隐患的。
7.19修改:
1.Objective-C中存在深拷贝、浅拷贝,即使你调用一个[NSMutableArray Copy]
得到的NSArray也不代表这个数组中的对象都是经过深拷贝的。
2.执行一个copy操作不代表是真的进行了copy,如果这个copy的对象是个immutable的对象,OC底层会将copy优化成retain。
锁
既然想到了多线程对于指针(或者对象)的修改,我们很理所当然的就会想到用锁。在现如今iOS博客泛滥的年代,大家都知道NSLock, OSSpinLock之类的可以用于短暂的Critical Section竞态的锁保护。
所以对于一些多线程中需要使用共享数据源并支持修改操作的时候,比如NSMutableArray添加一些object的时候,我们可以写出如下代码:
OSSpinLock(&_lock);
[self.array addObject:@"hahah"];
OSSpinUnlock(&_lock);
乍一看,这个没问题了,这个就是最基本的写保护锁。如果有多个代码同时尝试添加进入self.array
,是会通过锁抢占的方式一个一个的方式的添加。
但是,这个东西有什么主要用处吗?原子锁只能解决Race Condition的问题,但是它并不能解决任何你代码中需要有时序保证的逻辑。
比如如下这段代码:
if (self.xxx) {
[self.dict setObject:@"ah" forKey:self.xxx];
}
大家第一眼看到这样的代码,是不是会认为是正确的?因为在设置key的时候已经提前进行了self.xxx
为非nil的判断,只有非nil得情况下才会执行后续的指令。但是,如上代码只有在单线程的前提下才是正确的。
假设我们将上述代码目前执行的线程为Thread A
,当我们执行完if (self.xxx)
的语句之后,此时CPU将执行权切换给了Thread B
,而这个时候Thread B中调用了一句self.xxx = nil
。
那对于这种问题,我们有没有比较好的解决方案呢?答案是存在的,就是使用局部变量。
针对上述代码,我们进行如下修改:
__strong id val = self.xxx;
if (val) {
[self.dict setObject:@"ah" forKey:val];
}
这样,无论多少线程尝试对self.xxx
进行修改,本质上的val
都会保持现有的状态,符合非nil的判断。
Objective-C的Property Setter多线程并发bug
最后我们回到经常使用的Objective-C来谈谈现实生活中经常出现的问题。相信各位对于Property的Setter概念都不陌生,self.xxx = @"kks"
其实就是调用了xxx
的setter方法。而Setter方法本质上就是如下这样一段代码逻辑:
- (void)setXxx:(NSString *)newXXX {
if (newXXX != _xxx) {
[newXXX retain];
[_xxx release];
_userName = newXXX;
}
}
比如Thread A 和 B同时对self.xxx
进行了赋值,当两者都越过了 if (newXXX != _xxx)
的判断的时候,就会产生[_xxx release]
执行了两次,造成过度释放的crash危险。
有人说,呵呵,你这是MRC时代的写法,我用了ARC,没问题了吧。
ok,那让我们来看看ARC时代是怎么处理的,对于ARC中不复写Setter的属性(我相信是绝大多数情况),Objective-C的底层源码是这么处理的。
static inline void reallySetProperty(id self, SEL _cmd, id newValue,
ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
id oldValue;
// 计算结构体中的偏移量
id *slot = (id*) ((char*)self + offset);
if (copy) {
newValue = [newValue copyWithZone:NULL];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:NULL];
} else {
// 某些程度的优化
if (*slot == newValue) return;
newValue = objc_retain(newValue);
}
// 危险区
if (!atomic) {
// 第一步
oldValue = *slot;
// 第二步
*slot = newValue;
} else {
spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)];
_spin_lock(slotlock);
oldValue = *slot;
*slot = newValue;
_spin_unlock(slotlock);
}
objc_release(oldValue);
}
由于我们一般声明的对象都是nonatomic,所以逻辑会走到上述注释危险区处。还是设想一下多线程对一个属性同时设置的情况,我们首先在线程A处获取到了执行第一步代码后的oldValue,然后此时线程切换到了B,B也获得了第一步后的oldValue,所以此时就有两处持有oldValue。然后无论是线程A或者线程B执行到最后都会执行objc_release(oldValue)。
如果不相信的话,可以尝试如下这个小例子:
for (int i = 0; i < 10000; i++) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
self.data = [[NSMutableData alloc] init];
});
}
相信你很容易就能看到如下错误log:error for object: pointer being freed was not allocated。
结语
说了这么多,本质上线程安全是个一直存在并且相对来说是个比较困难的问题,没有绝对的银弹。用了Immutable不代表可以完全抛弃锁,用了锁也不代表高枕无忧了。希望这篇文章能够帮助大家更深入的思考下相关的问题,不要见到线程安全相关的问题就直接回答加锁、使用Immutable数据之类的。
当然,其实Stick To GCD (dispatch_barrier)是最好的解决方案。
如果各位大侠发现有出错的地方,也请大家提出,大家一起进步!