浅谈多线程编程中的误区

简介: 虽然很多程序员可以对异步、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)是最好的解决方案。

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

目录
相关文章
|
Java API 微服务
为什么虚拟线程将改变Java并发编程?
为什么虚拟线程将改变Java并发编程?
625 83
|
Java
如何在Java中进行多线程编程
Java多线程编程常用方式包括:继承Thread类、实现Runnable接口、Callable接口(可返回结果)及使用线程池。推荐线程池以提升性能,避免频繁创建线程。结合同步与通信机制,可有效管理并发任务。
453 6
|
机器学习/深度学习 消息中间件 存储
【高薪程序员必看】万字长文拆解Java并发编程!(9-2):并发工具-线程池
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发编程中的强力并发工具-线程池,废话不多说让我们直接开始。
532 0
|
算法 Java
Java多线程编程:实现线程间数据共享机制
以上就是Java中几种主要处理多线程序列化资源以及协调各自独立运行但需相互配合以完成任务threads 的技术手段与策略。正确应用上述技术将大大增强你程序稳定性与效率同时也降低bug出现率因此深刻理解每项技术背后理论至关重要.
733 16
|
Java 调度 数据库
Python threading模块:多线程编程的实战指南
本文深入讲解Python多线程编程,涵盖threading模块的核心用法:线程创建、生命周期、同步机制(锁、信号量、条件变量)、线程通信(队列)、守护线程与线程池应用。结合实战案例,如多线程下载器,帮助开发者提升程序并发性能,适用于I/O密集型任务处理。
1015 0
|
Linux
Linux编程: 在业务线程中注册和处理Linux信号
通过本文,您可以了解如何在业务线程中注册和处理Linux信号。正确处理信号可以提高程序的健壮性和稳定性。希望这些内容能帮助您更好地理解和应用Linux信号处理机制。
427 26
|
Linux
Linux编程: 在业务线程中注册和处理Linux信号
本文详细介绍了如何在Linux中通过在业务线程中注册和处理信号。我们讨论了信号的基本概念,并通过完整的代码示例展示了在业务线程中注册和处理信号的方法。通过正确地使用信号处理机制,可以提高程序的健壮性和响应能力。希望本文能帮助您更好地理解和应用Linux信号处理,提高开发效率和代码质量。
395 17
|
设计模式 Java 开发者
Java多线程编程的陷阱与解决方案####
本文深入探讨了Java多线程编程中常见的问题及其解决策略。通过分析竞态条件、死锁、活锁等典型场景,并结合代码示例和实用技巧,帮助开发者有效避免这些陷阱,提升并发程序的稳定性和性能。 ####
|
缓存 Java 开发者
Java多线程编程的陷阱与最佳实践####
本文深入探讨了Java多线程编程中常见的陷阱,如竞态条件、死锁和内存一致性错误,并提供了实用的避免策略。通过分析典型错误案例,本文旨在帮助开发者更好地理解和掌握多线程环境下的编程技巧,从而提升并发程序的稳定性和性能。 ####
|
安全 Java 开发者
Java中的多线程编程:从基础到实践
本文深入探讨了Java多线程编程的核心概念和实践技巧,旨在帮助读者理解多线程的工作原理,掌握线程的创建、管理和同步机制。通过具体示例和最佳实践,本文展示了如何在Java应用中有效地利用多线程技术,提高程序性能和响应速度。
368 11

热门文章

最新文章