从Immutable来谈谈对于线程安全的理解误区

毫不夸张的说,80%的程序员对于多线程的理解都是浅陋和错误的。就拿我从事的iOS行业来说,虽然很多程序员可以对异步、GCD等等与线程相关的概念说的天花乱坠。但是实质上深挖本质的话,大多数人并不能很好的区分Race Condition,Atomic,Immutable对象在线程安全中真正起到的作用。

所以今天就以这篇文章来谈谈我所理解的线程安全。

首先就允许我从Immutable来开始整篇话题吧。

Swift中的Immutable

用过Swift的人都知道,Swift相较于Objective-C有一个比较明显的改动就是将结构体(Struct)和类型(Class)进行了分离。从某种方面来说,Swift将值类型和引用类型进行了明显的区分。为什么要这么做?

  1. 避免了引用类型在被作为参数传递后被他人持有后修改,从而引发比较难以排查的问题。
  2. 在某些程度上提供了一定的线程安全(因为多线程本身的问题很大程序上出在写修改的不确定性)。而Immutable 数据的好处在于一旦创建结束就无法修改,因此相当于任一一个线程在使用它的过程中仅仅是使用了读的功能。

看到这,很多人开始欢呼了(嘲讽下WWDC那些“托”一般的粉丝,哈哈),觉得线程安全的问题迎刃而解了。

但事实上,我想说的是使用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]

上述这个代码片段,绝对是存在线程的安全的隐患的。

既然想到了多线程对于指针(或者对象)的修改,我们很理所当然的就会想到用锁。在现如今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);

于是,重复释放的场景就出现了,crash在向你招手哦!

如果不相信的话,可以尝试如下这个小例子:

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)是最好的解决方案。

本文写于头昏脑涨之中,写错之处请大神多多指出。