美文网首页
iOS之 Property的多线程安全理解

iOS之 Property的多线程安全理解

作者: 叶子丝 | 来源:发表于2019-10-10 20:40 被阅读0次

    最近在面试,根据一些面试题,先从一些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的多线程安全理解

          本文链接:https://www.haomeiwen.com/subject/jqiguctx.html