简介
多线程问题是指多线程环境下,同时访问相同内存或者资源,从而产生数据不一致问题,数据冲突,甚至导致 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...
}