【Swift】iOS 线程锁

5,973 阅读9分钟

Swift 中var生命的变量默认是非原子性的,如果要保证线程安全,我们就需要引入锁的感念。

注意:谨慎直接在Demo中用for+print()等来证明是否线程安全。因为print()方法本身是线程安全的,它可能会拯救你的不安全代码。第3节objc_sync部分的例子有print()和NSLog()的比较,结果仅作参考。

  1. 本文将着重介绍NSCondition以及DispatchSemaphore
  2. 本文介绍的内容Demo代码都是基于Swift4.0

一 互斥锁

iOS里的线程互斥锁主要有以下几种

  1. 遵循NSLocking协议. 包括NSLockNSConditionNSConditionLockNSRecursiveLock
  2. GCD. DispatchSemaphore, DispatchWorkItemFlags.barrier
  3. objc_sync. 包括 @synchronized
  4. pthread. 包括POSIX。POSIX比较底层,但是一般很少用了。在此对POSIX也不详述。文末有相关讨论的资源。

1. NSLocking协议

NSlocking协议本身仅仅定义了lock()和unlock()

public protocol NSLocking {

    
    public func lock() 

    public func unlock()
}

除了NSCondition,其它三种锁都是有以下两个方法

  1. open func `try`() -> Bool
    尝试锁,如果成功,返回true。这里需要注意的是,如果对一个已经调用lock()加锁的程序再次加锁会产生死锁,此时不会有返回值。(参考下面注意点4.1)

  2. open func lock(before limit: Date) -> Bool
    加锁,并且给这个锁一个过期时间,时间到了自动解锁。

1.1 NSLock

open class NSLock : NSObject, NSLocking {

    
    open func `try`() -> Bool 

    open func lock(before limit: Date) -> Bool

    
    @available(iOS 2.0, *)
    open var name: String?
}

最常用的锁。在需要加锁的地方lock(),然后在解锁的地方unlock()即可。

1.2 NSCondition

@available(iOS 2.0, *)

open class NSCondition : NSObject, NSLocking {

    /// 阻塞(休眠)线程。收到信号唤醒
    open func wait()
    /// 休眠当前线程,并设置一个自动唤醒的时间
    open func wait(until limit: Date) -> Bool 
    /// 发出信号 唤醒一个线程
    open func signal() 
     /// 发出信号,所用运用当前NSConditionh实例的wait()线程都会唤醒
    open func broadcast()

    
    @available(iOS 2.0, *)
    open var name: String?
}

下面详细介绍一下NSCondition

1.2.1. 执行步骤伪代码

lock the condition // 锁住condition
while (!(boolean_predicate)) { // 一个作为判断用的Bool值
    wait on condition // Wait()
}
do protected work // 执行任务代码
(optionally, signal or broadcast the condition again or change a predicate value) // 通过signal或者baroadcast来改变状态
unlock the condition // 解锁condition

1.2.2. 简介

wait()其实并不能直接用于锁住线程,作用原理如下。
调用condition wait()之后,condition 实例会解锁它已有的锁(保证同时只有一个锁)然后使当前调用的线程休眠。当 condition 被signal()通知后,系统会唤起线程。然后 condition 实例会在wait()或者wait(until:)方法结束的位置再次给线程加锁。因此,从线程的角度来看,就好像wait()一直在保有这个锁。
虽然wait()会给线程加锁,在测试的时候也确实可以按照期望运行,但是根据苹果官方文档, 单只使用wait()加锁并不能确保安全。所以,无论什么情况使用Condition的时候,第一步总是加锁。锁住当前condition可以确保判断和任务代码不会受其它使用相同condition的线程影响。

基于condition发信号的原理,使用Bool值来判断是非常重要的。给condition发射信号并不能保证condition本身为true。由于发信号的时间问题可能会导致假信号的出现。使用Bool值来判断可以确保之前的信号不会造成代码在还不能安全运行的时候执行。这个判断值就是一个很简单Bool标签,仅仅是用来判断信号是否发射完成。 这部分的内容其实和用 POSIX Thread Locks中的情形一样。 wait()函数的内部伪代码如下

unlock the thread
wait for the signal
lock the thread

在使用的时候应当如下(不包含Bool判断)

self.lock.lock()
self.lock.wait()
self.lock.unlock()

1.3 NSConditionLock

使用NSConditionLock,可以确保线程仅在condition符合情况时上锁,并且执行相应的代码,然后分配新的状态。状态值需要自己定义。

1.4 NSRecurisiveLock

NSRecursiveLock 定义了一种可以多次给相同线程上锁并不会造成死锁的锁。

2. GCD

GCD里的DispatchSemaphore,和DispatchWorkItemFlagBarrier也是可以达到线程锁的目的

2.1 DispatchSemaphore


open class DispatchSemaphore : DispatchObject {
}
extension DispatchSemaphore {

    public func signal() -> Int // 信号量增加1

    public func wait() // 信号量减少1

    public func wait(timeout: DispatchTime) -> DispatchTimeoutResult // 信号量减少1 并设置在timeout时间后加回这个减少的信号量1

    public func wait(wallTimeout: DispatchWallTime) -> DispatchTimeoutResult
}
extension DispatchSemaphore {
    @available(iOS 4.0, *)
    public /*not inherited*/ init(value: Int)
}

2.1.1 初始化

DispatchSemaphore(value: value)
value对应着最大信号值,所以信号值可以对应到如下应用场景

  1. 当初始化的value值等于0,适用于两个线程之间协调任务。
  2. 当初始化的value值小于0,会造成返回Null,初始化失败。
  3. 当初始化的value值大于0,适合于管理一个有限的资源池,资源池的大小等于value值。
    而对于某个使用DispatchSemaphore来加锁的线程来说,仅当当前信号量大于0时任务才会执行(可以理解成资源池有空闲资源)。

2.1.2 Demo

class GCDLockTest {
    let semaphore = DispatchSemaphore(value: 1) // 资源池1资源
    func test() {
        
        queueA.async {
            //直到通过signal()增加信号量
            self.semaphore.wait()  // 信号值 -1;资源池剩余资源0
            print("QueueA Gonna Sleep")
            sleep(3)
            print("QueueA Woke up")
            self.semaphore.signal()  // 信号值 +1;资源池剩余资源1
            
        }
        queueB.async {
            self.semaphore.wait()  // 信号值 -1 资源池剩余资源0
            print("QueueB Gonna Sleep")
            sleep(3)
            print("QueueB Work up")
            self.semaphore.signal()  // 信号值 +1 资源池剩余资源1

        }
        queueC.async {
            self.semaphore.wait()  // 信号值 -1 资源池剩余资源0
            print("QueueC Gonna Sleep")
            sleep(3)
            print("QueueC Wake up")
            self.semaphore.signal()  // 信号值 +1 资源池剩余资源1
            
        }
    }
}  

上述代码的输出将会是

QueueA Gonna Sleepd // 信号量-1 (当前任务正在进行,无空闲资源)
// 3秒 期间queueB, queueC 并没有执行,因为信号量初始化的值1,也就是最大允许1,可以理解为资源池只有一个资源
QueueA Woke up // QeueuA 完成 信号量+1  当前信号量1,资源释放,空闲资源有了。于是下一个线程开始执行
QueueB Gonna Sleep // QueueB执行 信号量-1
// 3秒
QueueB Work up // QueueB结束 信号量+1
QueueC Gonna Sleep // QueueC执行 信号量-1
// 3秒
QueueC Wake up // QueueC 结束 信号量+1

同理,如果初始化值为2,最大可以同时两个线程执行。
如果初始值是3的话我们的Demo中三个线程就都可以同时执行。
那如果初始化0呢?
显然,本例中的三个线程都将不能执行,因为信号量一直高于初始值。现在回看我们在2.1中提到的应用场景,是不是就很好理解。

我们将QueueAQueueB稍作改变

        queueA.async {
            //直到通过signal()增加信号量
            self.semaphore.wait()  // 信号值 +1
            print("QueueA Gonna Sleep")
            sleep(3)
            print("QueueA Woke up")
            self.semaphore.signal()  // 信号值 -1
            
        }
        queueB.async {
            self.semaphore.signal()  // 信号值 +1
            print("QueueB Gonna Sleep")
            sleep(3)
            print("QueueB Work up")
        }
  

这时的输出会是什么?

QueueB Gonna Sleep // 因为QueueA在执行的时候信号值+1,超过了0,所以只能等待
QueueA Gonna Sleep // 当QueueB执行的时候,信号值-1,没有超过0,所以QueueA就能执行了
/// 3秒
QueueB Work up
QueueA Woke up

所以,当初始化值为0时,就可以达到两个线程其中一个再另一个之后结束等功能。

注意: 如果在主线程中wait()会阻塞UI刷新

2.3 DispatchGroup

enter()是明确告诉GCD你要开始
leave()是明确标明任务结束
一般情况下不需要明确使用enter()/leave() 。 只有比如说,你的任务中包含其它异步任务,而你想要在这个子异步任务开始前就结束等待,那就可以使用leave()了。

3. objc_sync

3.1 objc_sync_enter/objc_sync_exit

class SyncTest {
    var count = 0
    func test() {
        count = 0
        
        let queueA = DispatchQueue(label: "Q1")
        let queueB = DispatchQueue(label: "Q2")
        let queueC = DispatchQueue(label: "Q3")
        
        queueA.async {
            for _ in 1...100 {
                NSLog("%i", self.increased())
//                print(self.increased())
            }
        }
        queueB.async {
            for _ in 1...100 {
                NSLog("%i", self.increased())
//                print(self.increased())
            }
        }
        queueC.async {
            for _ in 1...100 {
                NSLog("%i", self.increased())
//                print(self.increased())
            }
            
        }
      
        
    }
    func increased() -> Int {
        objc_sync_enter(count)
        count += 1
        objc_sync_exit(count)
        return count
    }
}

3.1.1

objc_sync_enter(object)方法会在object上开启同步(synchronize),如果成功返回OBJC_SYNC_SUCCESS, 否则返回OBJC_SYNC_NOT_OWNING_THREAD_ERROR ,直到objc_sync_exit(object)

objectAny类型。在本Demo中甚至可以直接传入self。但是它会锁住整个

二 自旋锁

主要介绍两种

  1. OSSpinLock。由于存在因为低优先级争夺资源导致的死锁,在iOS10.0之后已废弃,并引入下面的新方法。
  2. os_unfair_lock。替代OSSpinLock的自旋锁方案。需要导入os

三 性能比较

引用一张被广泛引用在此类文章中的图片来说明

根据我后来自己做的测试,OSSpinLock和os_unfair_lock以及dispatch_semaphore三者的性能是最优且接近的。

四 注意点

1. 串行队列即使异步执行也不会重新开新线程。参考第二点后面的例子。
2. 主线程队列是单一线程串行队列的。不要在主线程加锁,会导致UI刷新被阻塞。

for i in 1...10 {
            DispatchQueue.global().async {
                print("\(i)---\(Thread.current)")
            }
        }

      
for i in 1...10 {
            DispatchQueue.main.async {
                print("\(i)---\(Thread.current)")
            }
        }

      

输出

5---<NSThread: 0x60c000074280>{number = 11, name = (null)}
2---<NSThread: 0x60800006e500>{number = 5, name = (null)}
6---<NSThread: 0x60400007a0c0>{number = 6, name = (null)}
3---<NSThread: 0x60400007a140>{number = 10, name = (null)}
9---<NSThread: 0x60800006e6c0>{number = 12, name = (null)}
4---<NSThread: 0x600000069b40>{number = 4, name = (null)}
8---<NSThread: 0x60400007a080>{number = 3, name = (null)}
1---<NSThread: 0x60800006e600>{number = 9, name = (null)}
7---<NSThread: 0x60400007a100>{number = 8, name = (null)}
10---<NSThread: 0x60000046c6c0>{number = 7, name = (null)}

1---<NSThread: 0x60c000077d40>{number = 1, name = main}
2---<NSThread: 0x60c000077d40>{number = 1, name = main}
3---<NSThread: 0x60c000077d40>{number = 1, name = main}
4---<NSThread: 0x60c000077d40>{number = 1, name = main}
5---<NSThread: 0x60c000077d40>{number = 1, name = main}
6---<NSThread: 0x60c000077d40>{number = 1, name = main}
7---<NSThread: 0x60c000077d40>{number = 1, name = main}
8---<NSThread: 0x60c000077d40>{number = 1, name = main}
9---<NSThread: 0x60c000077d40>{number = 1, name = main}
10---<NSThread: 0x60c000077d40>{number = 1, name = main}

3. 并发和并行: 并行是线程被多个CPU内核执行,并发是线程轮流交替被单个CPU内核执行。
4. 上锁的英文 acquire a lock
5. 对一个已经lock()的锁再次调用lock()将会产生死锁,这也是递归锁引入的原因。递归锁实现的就是可以多次加锁也不会产生死锁。

BTW: 同样的死锁会产生在在同一个同步线程中调用这个线程的同步队列

五 资源

本例代码后续会上传到Github
苹果官方多线程编程指南
POSIX博客