关于“锁”的一些事儿

多线程在日常开发中会时不时遇到。首先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)

互斥锁属于忙等(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,及其效率


674591-176434d65ad6f5b6.png

挑几个我们常用且熟悉的啰嗦几句

@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则适用于低频场景如初始化或者紧急修复使用;

推荐阅读更多精彩内容

  • 引用自多线程编程指南应用程序里面多个线程的存在引发了多个执行线程安全访问资源的潜在问题。两个线程同时修改同一资源有...
    Mitchell阅读 1,060评论 1 7
  • iOS线程安全的锁与性能对比 一、锁的基本使用方法 1.1、@synchronized 这是我们最熟悉的枷锁方式,...
    Jacky_Yang阅读 1,234评论 0 17
  • 前言 iOS开发中由于各种第三方库的高度封装,对锁的使用很少,刚好之前面试中被问到的关于并发编程锁的问题,都是一知...
    喵渣渣阅读 2,444评论 0 31
  • 锁是一种同步机制,用于多线程环境中对资源访问的限制iOS中常见锁的性能对比图(摘自:ibireme): iOS锁的...
    LiLS阅读 690评论 0 4
  • 教授讲,不是认识字的人,都能教语文!这是对自认为谁都能教语文,评价语文课的人错误认识的纠正,同时也是对语文教师提了...
    小水月阅读 242评论 1 8