多线程在日常开发中会时不时遇到。首先APP会有一个主线程(UI线程),处理一些UI相关的逻辑。但是牵扯到网络、数据库等耗时的操作需要新开辟线程处理,避免“卡住”主线程,给用户留下不好的印象。多线程的好处不言而喻:幕后做事,不影响明面上的事儿。但是也有一些需要注意的地方,其中“资源抢夺”就是需要特别注意的一点。
资源抢夺
所谓资源抢夺就是多个线程同时操作一个数据。
下面这段代码很简单,就是往Preferences文件中存一个值,并读取出来输出
override func viewDidLoad() {
super.viewDidLoad()
// 写
saveData(key: identifier1, value: 1)
// 读
let result1 = readData(key: identifier1)
print(" result1: \(String(describing: result1))")
// 写
saveData(key: identifier2, value: 2)
// 读
print("result2: \(String(describing: result1))")
}
输出结果毫无疑问是
result1: 1
result2: 2
如果这么写
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 线程一操作
let queue1 = DispatchQueue(label: "queue1");
queue1.async {[weak self] in
// 写
self?.saveData(key: identifier, value: 1)
// 读
let result = self?.readData(key: identifier) ?? ""
print("queue1 result: \(String(describing: result))")
}
// 线程二操作
let queue2 = DispatchQueue(label: "queue2");
queue2.async {[weak self] in
// 写
self?.saveData(key: identifier, value: 2)
// 读
let result = self?.readData(key: identifier) ?? ""
print("queue2 result: \(String(describing: result))")
}
}
通常会认为 queue1 先输出 1, 然后 queue2 再输出 2。 但实际上...
循环打印的结果
queue1 result: 1
queue2 result: 2
queue2 result: 1
queue2 result: 2
queue1 result: 2
queue2 result: 2
queue2 result: 2
queue1 result: 1
刚才代码中的 queue1要读取并写入, 但很有可能 queue2 这时候也运行了, 它在 queue1 的写入操作没有完成之前就做了读取操作。 这时候他们两个读到值都是0, 就会造成两个都输出1。线程的调度是由操作系统来控制的,如果 queue2 调用的时, queue1 正好写入完成,这时就能得到正确的输出结果。 可如果 queue2 调起的时候 queue1 还没写入完成,那么就会出现输出同样结果的现象。 这一切都是由操作系统来控制。
解决
1、NSLock
NSLock 是 iOS 提供给我们的一个 API 封装, 可以很好的解决资源抢夺问题。 NSLock 就是对线程加锁机制的一个封装
使用示例:
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let lock = NSLock()
for _ in 0..<100 {
// 线程一操作
let queue1 = DispatchQueue(label: "queue1");
queue1.async {[weak self] in
lock.lock() // 锁起来
// 写
self?.saveData(key: identifier, value: 1)
// 读
let result = self?.readData(key: identifier) ?? ""
lock.unlock() // 解锁
print("queue1 result: \(String(describing: result))")
}
// 线程二操作
let queue2 = DispatchQueue(label: "queue2");
queue2.async {[weak self] in
lock.lock() // 锁起来
// 写
self?.saveData(key: identifier, value: 2)
// 读
let result = self?.readData(key: identifier) ?? ""
lock.unlock() // 解锁
print("queue2 result: \(String(describing: result))")
}
}
}
循环打印的结果
queue1 result: 1
queue2 result: 2
queue1 result: 1
queue2 result: 2
queue1 result: 2
queue2 result: 2
queue1 result: 1
queue2 result: 2
互斥锁(pthread_mutex_lock)
从实现原理上来讲,Mutex(互斥锁)属于sleep-waiting类型的锁。例如在一个多核的机器上有两个线程p1和p2,分别运行在Core1和 Core2上。假设线程p1想要通过pthread_mutex_lock操作去得到一个临界区(Critical Section)的锁,而此时这个锁正被线程p2所持有,那么线程p1就会被阻塞 (blocking),Core1 会在此时进行上下文切换(Context Switch)将线程p1置于等待队列中,此时Core1就可以运行其他的任务(例如另一个线程p3),而不必进行忙等待。
自旋锁(Spin lock)
先插个话题:在OC中定义属性时,很多人会认为如果属性具备 nonatomic 特质,则不使用 “同步锁”。其实在属性设置方法中使用的是自旋锁。
旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是 否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。其作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远 高于互斥锁。
虽然它的效率比互斥锁高,但是它也有些不足之处:
1、自旋锁一直占用CPU,他在未获得锁的情况下,一直运行--自旋,所以占用着CPU,如果不能在很短的时 间内获得锁,这无疑会使CPU效率降低。
2、在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁,调用有些其他函数也可能造成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。
因此我们要慎重使用自旋锁,自旋锁只有在内核可抢占式或SMP的情况下才真正需要,在单CPU且不可抢占式的内核下,自旋锁的操作为空操作。自旋锁适用于锁使用者保持锁时间比较短的情况下。
总结
这里贴一张ibireme做的测试图,介绍了一些iOS 中的锁的API,及其效率
挑几个我们常用且熟悉的啰嗦几句
@synchronized (属:互斥锁)
显然,这是我们最熟悉的加锁方式,因为这是OC层面的为我们封装的,使用起来简单粗暴。使用时 @synchronized 后面需要紧跟一个 OC 对象,它实际上是把这个对象当做锁来使用。这是通过一个哈希表来实现的,OC 在底层使用了一个互斥锁的数组(也就是锁池),通过对象的哈希值来得到对应的互斥锁。
-(void)criticalMethod
{
@synchronized(self)
{
//关键代码;
}
}
NSLock(属:互斥锁)
NSLock 是OC 以对象的形式暴露给开发者的一种锁,它的实现非常简单,通过宏,定义了 lock 方法:
#define MLOCK - (void) lock{\ int err = pthread_mutex_lock(&_mutex);\ // 错误处理 ……}
NSLock只是在内部封装了一个pthread_mutex,属性为PTHREAD_MUTEX_ERRORCHECK,它会损失一定性能换来错误提示。这里使用宏定义的原因是,OC 内部还有其他几种锁,他们的 lock 方法都是一模一样,仅仅是内部pthread_mutex互斥锁的类型不同。通过宏定义,可以简化方法的定义。NSLock比pthread_mutex略慢的原因在于它需要经过方法调用,同时由于缓存的存在,多次方法调用不会对性能产生太大的影响。
atomic原子操作(属:自旋锁)
即不可分割开的操作;该操作一定是在同一个cpu时间片中完成,这样即使线程被切换,多个线程也不会看到同一块内存中不完整的数据。如果属性具备 atomic 特质,则在属性设置方法中使用的是“自旋锁”。
什么情况下用什么锁?
1、总的来看,推荐pthread_mutex作为实际项目的首选方案;
2、对于耗时较大又易冲突的读操作,可以使用读写锁代替pthread_mutex;
3、如果确认仅有set/get的访问操作,可以选用原子操作属性;
4、对于性能要求苛刻,可以考虑使用OSSpinLock,需要确保加锁片段的耗时足够小;
5、条件锁基本上使用面向对象的NSCondition和NSConditionLock即可;
6、@synchronized则适用于低频场景如初始化或者紧急修复使用;