浅谈多线程编程中的误区

简介: 虽然很多程序员可以对异步、GCD等等与线程相关的概念说的天花乱坠。但是实质上深挖本质的话,大多数人并不能很好的区分Race Condition,Atomic,Immutable对象在线程安全中真正起到的作用。 所以今天就以这篇文章来谈谈我所理解的线程安全。 首先就允许我从Immutable来开始整篇话题吧。 ### Immutable 最近几年,Immutable这个说法开始越

虽然很多程序员可以对异步、GCD等等与线程相关的概念说的天花乱坠。但是实质上深挖本质的话,大多数人并不能很好的区分Race Condition,Atomic,Immutable对象在线程安全中真正起到的作用。

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

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

Immutable

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

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

7.19补充:

  1. 其实之前说这段只是想引出Objective-C下文中提到的问题,Swift中Immutable数据结构是线程安全的,这是毫无疑问的。
  2. 对于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

嘿嘿,后果如何,想必我不用多说了吧。

当然,你可以在每个getter里面包装一层锁,用来判断当前是不是nil,但是如果那样的话,你的读取property的性能也会直线下降。

那对于这种问题,我们有没有比较好的解决方案呢?答案是存在的,就是使用局部变量
针对上述代码,我们进行如下修改:

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

如果各位大侠发现有出错的地方,也请大家提出,大家一起进步!

目录
相关文章
|
2月前
|
安全 调度 开发者
什么是软件开发领域的并发编程
什么是软件开发领域的并发编程
22 0
|
2月前
|
存储 监控 算法
深入探究Java线程池:提升并发性能的利器
深入探究Java线程池:提升并发性能的利器
137 0
|
4月前
|
Java
并发编程的艺术:Java线程与锁机制的实践
并发编程的艺术:Java线程与锁机制的实践
280 1
|
8月前
|
算法 安全 Java
深入理解多线程编程:并发世界的探险
在计算机编程领域,随着多核处理器的普及,多线程编程成为了一种常见的技术。多线程编程可以提高程序的性能,充分利用多核处理器的计算能力。然而,多线程编程并不容易,它引入了并发性和同步问题,需要开发者仔细思考和处理线程之间的竞争条件。本文将深入探讨多线程编程的概念、技术和最佳实践,帮助读者更好地应对并发编程挑战。
107 0
|
12月前
|
存储 安全 Java
吃透Java线程安全问题(下)
吃透Java线程安全问题(下)
|
12月前
|
安全 Java 调度
吃透Java线程安全问题(上)
吃透Java线程安全问题(上)
|
安全 算法 Java
【并发编程技术】「技术辩证分析」在并发编程模式下进行线程安全以及活跃性问题简析
【并发编程技术】「技术辩证分析」在并发编程模式下进行线程安全以及活跃性问题简析
53 0
【并发编程技术】「技术辩证分析」在并发编程模式下进行线程安全以及活跃性问题简析
|
Java 测试技术 编译器
深刻理解JAVA并发中的有序性问题和解决之道
深刻理解JAVA并发中的有序性问题和解决之道
104 0
深刻理解JAVA并发中的有序性问题和解决之道
|
存储 设计模式 Java
74. 对多线程熟悉吗,来谈谈线程池的好处?
74. 对多线程熟悉吗,来谈谈线程池的好处?
98 0
|
存储 缓存 安全

热门文章

最新文章