iOS - 理解线程安全

803 阅读14分钟

简介

多线程问题是指多线程环境下,同时访问相同内存或者资源,从而产生数据不一致问题,数据冲突,甚至导致 Crash。

线程安全则相反,所有线程能获取到满足需求的数据或者资源,而不发生意外。

多线程对于应用程序执行效率的提升不可或缺,充分发挥了并发执行的作用,执行更快,更充分利用资源。

线程安全对于应用程序的稳定性来说也至关重要。多线程问题通常是偶现的问题,通常在开发和测试环境均不能稳定复现,从而带上线影响线上稳定性。

原理

竞争条件

多线程环境同时访问相同资源的时候,会产生竞争条件 (Race Condition) 的风险。在这样的状态下,程序的运行取决于线程的时间顺序。假设两个线程在同时修改同一个值,这个值取决于哪个线程先完成,这可能会导致数据不一致,数据冲突,甚至Crash的问题。

假设有两个线程,各自会将同一个全局数值变量加1。理想状态下,它们会以这样的顺序执行:

线程1线程2数据值
0
读取(0)0
增加(1)0
写回1
读取(1)1
增加(2)1
写回2

在上文的理想状态下,运行的结果为预期的2。但是,如果两个线程在没有锁定或通过信号量同步的情况下同时运行,执行的结果将可能出错。如下展示了另一种情况:

线程1线程2数据值
0
读取(0)0
读取(0)0
增加(1)0
增加(1)0
写回1
写回1

在这种情况下,因为线程的增加操作没有互斥,导致最终结果为1,而不是预期的2。

多线程

线程和进程

通常理解为线程是最小的可调度单位。

进程是最小的持有资源单位。

每个进程的虚拟地址空间是独立的,只能通过进程间通信来(IPC)来交互。

线程(Thread)就是运行在进程上下文中的逻辑流,每个线程都有自己独立的上下文,所有运行在同一个进程中的线程共享该进程的整个虚拟地址空间。

主线程和子线程

在当前主流的线程分配模型中,主线程主要负责UI相关的处理,其他的任务尽量放到子线程中处理,从而保证UI渲染相关的性能。

主线程

UI刷新,交互响应,Timer执行。由于显示设备需要按照显示频率刷新,通常是60Hz,所以处理UI的主线程每次执行都需要在16ms内完成

子线程

网络请求,数据处理,文件IO,图片处理,数据库访问等

从存储器结构图上看,比如对存储器中 L5L6 的访问,网络请求和 IO 操作等

所以在进行对应的UI开发,或者耗时任务开发的时候,需要确认读写数据的环境。这时候主线程和子线程都对某一个对象,同一个内存地址有读写操作的话,很可能会产生对应的线程安全问题。

线程安全问题的 Crash

通常线程安全问题会导致数据一致性的问题,但是在部分情况下会导致Crash。Crash 是指遇到CPU无法执行的指令等异常,导致应用的被意外终止。

比较常见的线程安全问题的 Crash 是 Mach Exception 为 EXC_BAD_ACCESS 的问题,主要是内存过度释放,第二次释放的时候访问的是无效内存地址。

EXC_BAD_ACCESS 通常是读取或修改了与对应权限不相符合的内存地址,在虚拟内存中,内存是按照读写权限,只读权限区分的。比如在数据所在的内存已释放,或者修改只读权限下的内存,会导致CPU无法执行对应的指令,导致Crash产生。

Crash 的原因

线程安全问题的Crash主要是由于iOS内存管理机制导致的,由于使用了自动引用计数 (Auto Reference Counting) 机制。

也不是所有的对象都会由 ARC 管理内存,需要 malloc 的对象才会由 ARC 管理内存,比如一些基础数据类型,数据的值存储在对象本身当中,不会指向 malloc 的内存区域,就不会由 ARC 进行内存管理。

iOS 中,内存管理使用ARC机制,Runtime和编译器在编译阶段共同完成 Retain/Release/Autorelease 代码的插入。通过 Retain/Release 对象的 RetainCount 会变化,当 RetainCount = 0 的时候,对象的内存会被释放。

可以通过 swiftc 编译命令查看

-dump-ast              解析和类型检查源文件 & 转换成 AST
-dump-parse            解析源文件 & 转换成 AST  
  -emit-assembly         生成汇编文件
  -emit-bc               生成 LLVM Bitcode 文件
  -emit-executable       生成已链接的可执行文件
  -emit-imported-modules 生成已导入的库
  -emit-ir               生成 LLVM IR 文件
  -emit-library          生成已连接的库
  -emit-object           生成目标文件
  -emit-silgen           生成 raw SIL 文件(第一个阶段)
  -emit-sil              生成 canonical SIL 文件(第2个阶段)
  -index-file            为源文件生成索引数据
  -print-ast             解析和类型检查源文件 & 转换成更简约的格式更好的 AST
  -typecheck             解析和类型检查源文件
// 将m ain.swift 编译成 SIL 代码
swiftc -emit-sil main.swift 

// 将 main.swift 编译成 SIL,并保存到 main.sil 文件中
swiftc -emit-sil main.swift >> main.sil

// 将 main.swift 编译成 SIL的同时, 将命名重整后的符号恢复原样,并保存到 main.sil 文件中
swiftc -emit-sil main.swift | xcrun swift-demangle >> main.sil

在 SIL 中可以找到自动插入的 Retain/Release 代码

属性访问器中的内存管理

对属性访问器的内存管理有几种实现方式

  • 方式一
- (NSString *) title {
    return [[title retain] autorelease];
}

- (void) setTitle: (NSString *) newTitle {
    if (title != newTitle) {
        [title release];
        title = [newTitle retain]; // Or copy, depending on your needs.
    }
}

getter 方法中使用了 autorelease,当值发生改变的时候,依然数据依然可用,这样更加安全,但是如果 getter 调用频繁的话,增加了开销。

  • 方式二
- (NSString *) title {
    return title;
}

- (void) setTitle: (NSString *) newTitle {
    [title autorelease];
    title = [newTitle retain]; // Or copy, depending on your needs.
}

当 getter 调用比 setter 频繁很多的话,可以这么实现

  • 方式三
- (NSString *) title {
    return title;
}

- (void) setTitle: (NSString *) newTitle {
    if (newTitle != title) {
        [title release];
        title = [newTitle retain]; // Or copy, depending on your needs.
    }
}

这种方式就不再使用 autorelease,对象不会延长生命周期,autorelease 会保留对象等待到下一个 autoreleasepool drain 的时候。缺点在于旧的值如果没有其他地方引用的话,就立即释放了,这会导致如果有其他地方对此持有非引用关系的指向的话,就变成了悬垂指针。

NSString *oldTitle = [anObject title];
[anObject setTitle:@"New Title"];
NSLog(@"Old title was: %@", oldTitle);

像这段代码描述的一样,NSLog中由于 oldTitle 已经被释放,会导致指针访问到不可用内存,导致 Crash。这样的场景在多线程环境下更加常见。

在函数返回之前,oldTitle释放之后,另外的线程也在操作相同的内存地址的话,会触发对 oldTitle 的访问,导致出现Crash。多数情况下对 oldTitle 的访问是 ARC 对应的 Retain 和 Release 操作,所以 Crash 崩溃的堆栈信息应该不仅是 Release 也会有 Retain,不仅是过度释放会导致此处的 Crash。

野指针

指向不可用内存区域的指针(比如:在首次使用之前没有进行必要的初始化,垂悬指针也是可以算是野指针的一种)。通常对这种指针进行操作的话,将会使程序发生不可预知的错误。

悬垂指针

指向曾经存在的对象,但该对象已经不再存在了,此类指针称为悬垂指针。结果未定义,往往导致程序错误,而且难以检测。

原子性 Atomic

原子性是指一个不能被分割和打断的事物,要么都发生,要么都不发生。

Atomic 属性修饰符

iOS 中最常见的原子性,是Objective-C中的属性修饰符,这个原子性属性修饰符在属性访问器 Set 和 Get 内部通过加锁实现。

赋值

static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
    if (offset == 0) {
        object_setClass(self, newValue);
        return;
    }

    id oldValue;
    id *slot = (id*) ((char*)self + offset);

    if (copy) {
        newValue = [newValue copyWithZone:nil];
    } else if (mutableCopy) {
        newValue = [newValue mutableCopyWithZone:nil];
    } else {
        if (*slot == newValue) return;
        newValue = objc_retain(newValue);
    }

    if (!atomic) {
        oldValue = *slot;
        *slot = newValue;
    } else {
        spinlock_t& slotlock = PropertyLocks[slot];
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue;        
        slotlock.unlock();
    }

    objc_release(oldValue);
}

取值

id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
    if (offset == 0) {
        return object_getClass(self);
    }

    // Retain release world
    id *slot = (id*) ((char*)self + offset);
    if (!atomic) return *slot;
        
    // Atomic retain release world
    spinlock_t& slotlock = PropertyLocks[slot];
    slotlock.lock();
    id value = objc_retain(*slot);
    slotlock.unlock();
    
    // for performance, we (safely) issue the autorelease OUTSIDE of the spinlock.
    return objc_autoreleaseReturnValue(value);
}

相当于在Set 和 Get 方法中使用2把锁,主要是分别保证Set和Get方法的原子性

var _dict = [String: String]()
    var dict: [String: String] {
        set {
            LockA.lock()
            defer { Lock.unlock() }
            _dict = newValue
        }
        get {
            LockB.lock()
            defer { Lock.unlock() }
            return _dict
        }
    }

使用同一把锁,才是保证线程安全的方式

var rwlock = pthread_rwlock_t()
    
init() {
    pthread_rwlock_init(&rwlock, nil)
}

deinit {
    pthread_rwlock_destroy(&rwlock)
}

var _dict = [String: String]()
var dict: [String: String] {
    set {
        pthread_rwlock_wrlock(&rwlock)
        defer { pthread_rwlock_unlock(&rwlock) }
        _dict = newValue
    }
    get {
        pthread_rwlock_rdlock(&rwlock)
        defer { pthread_rwlock_unlock(&rwlock) }
        return _dict
    }
}

仅仅使用Objective-C中原子性属性修饰符修饰的属性,并不能保证线程安全,但是可以保证在 Set 方法并发,或者 Get 方法并发时候的线程安全。但是 Set 和 Get 之间并不是线程安全的。

原子性与线程安全的关系

回到这两个图表中,我们不难看出,只要读取、增加和写回操作连续发生不被打断,此处就不会造成线程安全问题,从而避免出现数据一致性问题。

相当于给读取、增加和写回操作当成一个具有原子性的事物,此处的原子性可以通过加锁或者串形队列来实现。

线程1线程2数据值
0
读取(0)0
增加(1)0
写回1
读取(1)1
增加(2)1
写回2
线程1线程2数据值
0
读取(0)0
读取(0)0
增加(1)0
增加(1)0
写回1
写回1

实践

保持共享数据最小化

减少单例使用

单例可以在项目中的任意地方访问,增加了共享数据的范围

// Module A
class ClassA {
    func mainThreadMethod {
        Singleton.Shared.dict = ["key1": "value1"]
    }
}

// Module B
class ClassB {
    func subThreadMethod {
        Singleton.Shared.dict = ["key2": "value2"]
    }
}

减少引用传递

引用类型

赋值的时候是引用传递,引用指向相同的内存区域,增加了共享数据的范围

class MyClass {
    var value: Int
    init(value: Int) {
        self.value = value
    }
}

var a = MyClass(value: 10)
var b = a
b.value = 20
print(a.value) // prints 20
print(b.value) // prints 20
值类型

赋值的时候是深拷贝

var x = 10
var y = x
y = 20
print(x) // prints 10
print(y) // prints 20
Class 与 Struct

Class 类型是引用传递,所以 Class 中的属性即使是值类型,也会受到引用传递的影响

  • 通过 Class 获取引用的方式,是引用传递,此时改变属性的值,原内存地址数据会受到影响
  • 通过 Class 的值类型属性赋值的方式,是值传递,改变属性的值,原内存地址数据不会受到影响
基础数据类型
  • Int, Bool, Float, Double, Decimal

  • String

  • Collection

    • Array
    • Set
  • Dictionary

    var intValue = 0
    var shortString = "name"
    var longString = "1234567890abcdef"
    
    func test() {
        for i in 0...1000 {
            DispatchQueue.global().async {
                self.intValue = self.intValue + 1
//                self.shortString = "name_(i)" // 数据一致性问题
//                self.longString = "1234567890abcdef_(i)" // Crash问题
            }
            
            print(self.intValue)
//            print(self.shortString)
//            print(self.longString)
        }
    }

从示例代码中可以看出,多线程读写对应属性的时候会导致数据一致性的问题。对于 String 类型来说,以 16 位的长度分界,长字符串多线程读写会 Crash 而短字符串不会。

短字符串由于使用了 Tagged Pointer 技术,值直接存储在了指针当中,所以不会有 ARC 中的 Retain/Release,进而不会存在对同一内存地址过度释放的问题。

所以从内存管理的角度短字符串跟 Int 等基础数据类型一样,而长字符串跟Array, Set, Dictionay 类型一样。但是从值类型的角度,所有的基础数据类型在赋值的时候,甚至 Dictionay 类型通过 subscript 通过 key 赋值的时候,都是深拷贝的机制。这也是为什么 Dictionay 类型的对象,通过对 Set Get 加锁可以解决 Key Value 数值改变造成的线程安全问题。

尽量使用不可变数据类型

尽量使用 immutable 数据类型,Swift中尽量使用Let,从编译层面防止外部获取引用之后修改数据。

尽量低开销使用同步

Lock

锁的种类
自旋锁

Busy waiting

互斥锁
读写锁

读读 不互斥

读写 互斥

写写 互斥

锁的性能

  • OSSpinLock 由于优先级反转问题已经被 os_unfair_lock 替代

串形队列 Queue

串形队列,异步任务

let serialQueue = DispatchQueue(label: "serialQueue")
serialQueue.async {
    // Codes
}

注意事项

优先级反转

如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,它会处于 spin lock 的忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成,无法释放 lock。

GCD 死锁

在相同的串行队列中,添加同步任务,会导致当前的队列需要等添加后的任务执行完成,而同步任务需要等串行队列中的代码执行完成,造成死锁。

保持临界区最短

临界区 (Critical Section) 是指包含有共享数据的一段代码,这些代码可能被多个线程访问或修改。 临界区的存在就是为了保证当有一个线程在临界区内执行的时候,不能有其他任何线程被允许在临界区执行。

如下的写法临界区较长,整个 foo 方法内部都属于临界区

func foo() {
    // Lock.lock()
    
    // Codes
    self.dict = ["key1": "key2"]
    // Codes
    
    // Lock.unlock()
}

如果只是对 dict 的 Set 和 Get 加锁可以有效的缩短临界区。这是为什么我们尽量在属性的Set Get方法内加锁,而不是在读取和写入处理的地方加锁。

var rwlock = pthread_rwlock_t()
    
init() {
    pthread_rwlock_init(&rwlock, nil)
}

deinit {
    pthread_rwlock_destroy(&rwlock)
}

var _dict = [String: String]()
var dict: [String: String] {
    set {
        pthread_rwlock_wrlock(&rwlock)
        defer { pthread_rwlock_unlock(&rwlock) }
        _dict = newValue
    }
    get {
        pthread_rwlock_rdlock(&rwlock)
        defer { pthread_rwlock_unlock(&rwlock) }
        return _dict
    }
}

PropertyWrapper

RWProtected

使用读写锁用于属性保护的属性包裹器

调用示例

    @RWProtected
    var price: String?

实现代码

// MARK: - RWLock
private protocol RWLock {
    func rdlock()
    func wrlock()
    func unlock()
}

extension RWLock {
    
    /// Executes a closure returning a value while acquiring the readlock.
    ///
    /// - Parameter closure: The closure to run.
    ///
    /// - Returns:           The value the closure generated.
    func aroundRead<T>(_ closure: () throws -> T) rethrows -> T {
        rdlock(); defer { unlock() }
        return try closure()
    }
    
    /// Execute a closure while acquiring the writelock.
    ///
    /// - Parameter closure: The closure to run.
    func aroundWrite(_ closure: () throws -> Void) rethrows {
        wrlock(); defer { unlock() }
        try closure()
    }
}

final class ReadWriteLock: RWLock {
    
    private var rwLock = pthread_rwlock_t()
    
    init() {
        pthread_rwlock_init(&rwLock, nil)
    }
    
    deinit { 
        pthread_rwlock_destroy(&rwLock)
    }
    
    fileprivate func rdlock() {
        pthread_rwlock_rdlock(&rwLock)
    }
    
    fileprivate func wrlock() {
        pthread_rwlock_wrlock(&rwLock)
    }
    
    fileprivate func unlock() {
        pthread_rwlock_unlock(&rwLock)
    }
}
/// A thread-safe wrapper around a value.
@propertyWrapper
@dynamicMemberLookup
public final class RWProtected<T> {
    
    private let lock = ReadWriteLock()
    private var value: T
    
    init(_ value: T) {
        self.value = value
    }
    
    public var wrappedValue: T {
        get { lock.aroundRead { value } }
        set { lock.aroundWrite { value = newValue } }
    }
    
    var projectedValue: Protected<T> { self }
    
    public init(wrappedValue: T) {
        value = wrappedValue
    }
    
    /// Synchronously read or transform the contained value.
    ///
    /// - Parameter closure: The closure to execute.
    ///
    /// - Returns:           The return value of the closure passed.
    func read<U>(_ closure: (T) throws -> U) rethrows -> U {
        try lock.around { try closure(self.value) }
    }

    /// Synchronously modify the protected value.
    ///
    /// - Parameter closure: The closure to execute.
    ///
    /// - Returns:           The modified value.
    @discardableResult
    func write<U>(_ closure: (inout T) throws -> U) rethrows -> U {
        try lock.aroundWrite { try closure(&self.value) }
    }

    subscript<Property>(dynamicMember keyPath: WritableKeyPath<T, Property>) -> Property {
        get { lock.aroundRead { value[keyPath: keyPath] } }
        set { lock.aroundWrite { value[keyPath: keyPath] = newValue } }
    }

    subscript<Property>(dynamicMember keyPath: KeyPath<T, Property>) -> Property {
        lock.aroundRead { value[keyPath: keyPath] }
    }
}
Protected

使用 os_unfair_lock 实现的用于属性保护的属性包裹器

负载和压力测试

由于线程安全问题的偶发性特点,测试只有达到一定的负载和压力的情况下才有可能复现

APP 分层

  • UI 交互层
  • ViewModel 业务数据层
  • Model 网络数据层

UI 交互层 可以通过 UI 自动化和 UI 测试来进行一定的压力测试

单元测试 对业务数据和网络数据进行测试

线程安全的数据类型

NSCache

UserDefaults

IDE 工具

Thread Sanitizer

Main Thread Checker

语言规范

iOS 中在提供部分API的时候,断言可以作为对线程限制的提醒

func uimethod() {
    assert(Thread.isMainThread)
    // Codes...
}