iOS之 Property的多线程安全理解

最近在面试,根据一些面试题,先从一些iOS一些简单的基础题开始。今天主要是写属性(property)的多线程安全的理解,记录下来。

当我们讨论property多线程安全的时候,很多人都知道给property加上atomic之后,可以一定程度的保障多线程安全。那先来说说对象的线程安全是否与原子性有关。

nonatomic和atomic

原子性:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch。将整个操作视作一个整体是原子性的核心特征。

atomic:

  • 系统生成的 getter/setter 会保证 get、set 操作的完整性,不受其他线程影响。getter 还是能得到一个完好无损的对象(可以保证数据的完整性),但这个对象在多线程的情况下是不能确定的,比如上面的例子。

  • 也就是说:如果有多个线程同时调用setter的话,不会出现某一个线程执行完setter全部语句之前,另一个线程开始执行setter情况,相当于函数头尾加了锁一样,每次只能有一个线程调用对象的setter方法,所以可以保证数据的完整性。

  • atomic所说的线程安全只是保证了getter和setter存取方法的线程安全,并不能保证整个对象是线程安全的。

nonatomic:

  • 就没有这个保证了,nonatomic返回你的对象可能就不是完整的value。因此,在多线程的环境下原子操作是非常必要的,否则有可能会引起错误的结果。但仅仅使用atomic并不会使得对象线程安全,我们还要为对象线程添加lock来确保线程的安全。

被atomic修饰的属性(不重载设置器和访问器)只保证了对数据读写的完整性,也就是原子性,但是与对象的线程安全无关。
由于atomic具有一定的性能开销,所以我们应该把所有的属性都用nonatomic修饰。

Property

要分析property在多线程场景下的表现,需要先对property的类型做区分。
将property分为值类型和对象类型,值类型是指primitive type,包括int, long, bool等非对象类型,另一种是对象类型,声明为指针,可以指向某个符合类型定义的内存区域。

@property (atomic, strong) NSString*                 userName;

上述代码中userName明显是个对象类型,当我们访问userName的时候,访问的有可能是userName本身,也有可能是userName所指向的内存区域。

//是对指针本身的访问
self.userName = @"peak";
//访问指针指向的字符串所在的内存区域
[self.userName rangeOfString:@"peak"];

所以将property分为以下三类:


image.png

三种property的内存模型

当我们讨论多线程安全的时候,其实是在讨论多个线程同时访问一个内存区域的安全问题。针对同一块区域,我们有两种操作,读(load)和写(store),读和写同时发生在同一块区域的时候,就有可能出现多线程不安全。

property的内存模型.png
//修改第一块区域。
self.userName = @"peak";
//修改第二块区域
self.count = 10;
//修改第三块区域
[self.userName rangeOfString:@"peak"];

上图property的内存模型.png以64位系统为例,指针NSString*是8个字节的内存区域,int count是个4字节的区域,而@“Peak”是一块根据字符串长度而定的内存区域。所以当我们访问property的时候,实际上是访问上图中三块内存区域。

接下来我们根据上面三种property的分类逐一看下多线程的不安全场景。

  • 值类型Property
@property (nonatomic, assgin) BOOL    isDeleted;

//thread 1
bool isDeleted = self.isDeleted;

//thread 2
self.isDeleted = false;

先以BOOL值类型为例,当我们有两个线程访问如下property的时候。线程1和线程2,一个读(load),一个写(store),对于BOOL isDeleted的访问可能有先后之分,但一定是串行排队的。而且由于BOOL大小只有1个字节,64位系统的地址总线对于读写指令可以支持8个字节的长度,所以对于BOOL的读和写操作我们可以认为是原子的,所以当我们声明BOOL类型的property的时候,从原子性的角度看,使用atomic和nonatomic并没有实际上的区别(当然如果重载了getter方法就另当别论了)。

atomic到底有什么用呢?据我所知,用处有二:

用处一: 生成原子操作的getter和setter。

设置atomic之后,默认生成的getter和setter方法执行是原子的。也就是说,当我们在线程1执行getter方法的时候(创建调用栈,返回地址,出栈),线程B如果想执行setter方法,必须先等getter方法完成才能执行。举个例子,在32位系统里,如果通过getter返回64位的double,地址总线宽度为32位,从内存当中读取double的时候无法通过原子操作完成,如果不通过atomic加锁,有可能会在读取的中途在其他线程发生setter操作,从而出现异常值。如果出现这种异常值,就发生了多线程不安全。

用处二:设置Memory Barrier

对于Objective C的实现来说,几乎所有的加锁操作最后都会设置memory barrier,atomic本质上是对getter,setter加了锁,所以也会设置memory barrier。官方文档表述如下:

Note: Most types of locks also incorporate a memory barrier to ensure that any preceding load and store instructions are completed before entering the critical section.

memory barrier能够保证内存操作的顺序,按照我们代码的书写顺序来。听起来有点不可思议,事实是编译器会对我们的代码做优化,在它认为合理的场景改变我们代码最终翻译成的机器指令顺序。

是不是使用了atomic就一定多线程安全呢?

@property (atomic, assign)    int       intA;

//thread A
for (int i = 0; i < 10000; i ++) {
    self.intA = self.intA + 1;
    NSLog(@"Thread A: %d\n", self.intA);
}

//thread B
for (int i = 0; i < 10000; i ++) {
    self.intA = self.intA + 1;
    NSLog(@"Thread B: %d\n", self.intA);
}

看上面的代码self.intA = self.intA + 1;不是原子操作,虽然intA的getter和setter是原子操作,但当我们使用intA的时候,整个语句并不是原子的,这行赋值的代码至少包含读取(load),+1(add),赋值(store)三步操作,当前线程store的时候可能其他线程已经执行了若干次store了,导致最后的值小于预期值。所以我们的答案是:即使我将intA声明为atomic,多线程不一定安全。

  • 指针Property
    指针Property一般指向一个对象,无论iOS系统是32位系统还是64位,一个指针的值都能通过一个指令完成load或者store。但与primitive type不同的是,对象类型还有内存管理的相关操作。在MRC时代,系统默认生成的setter类似如下:
- (void)setUserName:(NSString *)userName {
    if(_uesrName != userName) {
        [userName retain];
        [_userName release];
        _userName = userName;
    }
}

不仅仅是赋值操作,还会有retain,release调用。如果property为nonatomic,上述的setter方法就不是原子操作,我们可以假设一种场景,线程1先通过getter获取当前_userName,之后线程2通过setter调用[_userName release];,线程1所持有的_userName就变成无效的地址空间了,如果再给这个地址空间发消息就会导致crash,出现多线程不安全的场景。

到了ARC时代,Xcode已经替我们处理了retain和release,绝大部分时候我们都不需要去关心内存的管理,但retain,release其实还是存在于最后运行的代码当中,atomic和nonatomic对于对象类的property声明理论上还是存在差异,不过我在实际使用当中,将NSString*设置为nonatomic也从未遇到过上述多线程不安全的场景,极有可能ARC在内存管理上的优化已经将上述场景处理过了,所以我个人觉得,如果只是对对象类property做读写,atomic和nonatomic在多线程安全上并没有实际差别。

  • 指针指向的内存区域
    即使我们声明property为atomic,依然会出错。因为我们访问的不是property的指针区域,而是property所指向的内存区域。
@property (atomic, strong) NSString*                 stringA;

//thread A
for (int i = 0; i < 100000; i ++) {
    if (i % 2 == 0) {
        self.stringA = @"a very long string";
    }
    else {
        self.stringA = @"string";
    }
    NSLog(@"Thread A: %@\n", self.stringA);
}

//thread B
for (int i = 0; i < 100000; i ++) {
    if (self.stringA.length >= 10) {
        NSString* subStr = [self.stringA substringWithRange:NSMakeRange(0, 10)];
    }
    NSLog(@"Thread B: %@\n", self.stringA);
}

即使stringA是atomic,两个线程同时对相同的内存地址进行读写访问,立即出现out of bounds的Exception,crash,多线程不安全。

小结:

简而言之,atomic的作用只是给getter和setter加了个锁,atomic只能保证代码进入getter或者setter函数内部时是安全的,一旦出了getter和setter,多线程安全只能靠程序员自己保障了。所以atomic属性和使用property的多线程安全并没什么直接的联系。另外,atomic由于加锁也会带来一些性能损耗,所以我们在编写iOS代码的时候,一般声明property为nonatomic,在需要做多线程安全的场景,自己去额外加锁做同步。注意,读和写都需要加锁。

你可能感兴趣的:(iOS之 Property的多线程安全理解)