iOS 线程安全和锁机制

2,556 阅读4分钟

一、线程安全场景

多个线程中同时访问同一块资源,也就是资源共享。多牵扯到对同一块数据的读写操作,可能引发数据错乱问题。

比较经典的线程安全问题有购票和存钱取钱问题,为了说明读写操作引发的数据混乱问题,以存钱取钱问题来做个说明。

1. 购票案例

购票.drawio.png

用代码示例如下:

@IBAction func ticketSale() {

        tickets = 30

        let queue = DispatchQueue.global()

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

    }

    //卖票

    func sellTicket() {

        var oldTicket = tickets

        sleep(UInt32(0.2))

         oldTicket -= 1

         tickets = oldTicket

        print("还剩\(tickets)张票 ---- \(Thread.current)")

    }

同时有三条线程进行卖票,对余票进行减操作,三条线程每条卖10次,原定票数30,应该剩余票数为0,下面看下打印结果:

截屏2023-08-04 10.47.47.png

可以看到打印票数不为0

2. 存钱取钱案例

先用个图说明

未命名绘图.drawio.png

上图可以看出,存钱和取钱之后的余额理论上应该是500,但由于存钱取钱同时访问并修改了余额,导致数据错乱,最终余额可能变成了400,下面用代码做一下验证说明:

//存钱取钱

    @IBAction func remainTest() {

        remain = 500

        let queue = DispatchQueue.global()

        queue.async {

            for _ in 0..<5 {

                self.saveMoney()

            }

        }

        queue.async {

            for _ in 0..<5 {

                self.drawMoney()

            }

        }

    }

    //存钱

    func saveMoney() {

       var oldRemain = remain

        sleep(2)

        oldRemain += 100

        remain = oldRemain

        print("存款100元,账户余额还剩\(remain)元 ----\(Thread.current)")

    }

    

    //取钱

    func drawMoney() {

        var oldRemain = remain

         sleep(2)

         oldRemain -= 50

         remain = oldRemain

        print("取款50元,账户余额还剩\(remain)元 ---- \(Thread.current)")

    }

上述代码存款5次100,取款5次50,最终的余额应该是 500 + 5 * 100 - 5 * 50 = 750

截屏2023-08-04 10.40.58.png

如图所示,可以看到在存款取款之间已经出现错乱了

上述两个案例之所以出现数据错乱问题,就是因为有多个线程同时操作了同一资源,导致数据不安全而出现的。

那么遇到这个问题该怎么解决呢?自然而然的,我们想到了对资源进行加锁处理,以此来保证线程安全,在同一时间,只允许一条线程访问资源。

加锁的方式大概有以下几种:

  • OSSpinLock
  • os_unfair_lock
  • pthread_mutex
  • dispatch_semaphore
  • dispatch_queue(DISPATCH_QUEUE_SERIAL)
  • NSLock
  • NSRecursiveLock
  • NSCondition
  • NSConditionLock

1. OSSpinLock 自旋锁

截屏2023-08-04 11.03.02.png

OSSpinLock 是自旋锁,在系统框架 libkern/OSAtomic

截屏2023-08-04 11.04.56.png

如图,系统提供了以下几个API

  • 定义lock let osspinlock = OSSpinLock()
  • OSSpinLockTry

官方给定的解释如下

Locks a spinlock if it would not block
return false, if the lock was already held by another thread,
return true, if it took the lock successfully.

尝试加锁,加锁成功则继续,加锁失败则直接返回,不会阻塞线程

  • OSSpinLockLock
Although the lock operation spins, it employs various strategies to back

off if the lock is held. 

加锁成功则继续,加锁失败,则会阻塞线程,处于忙等状态

  • OSSpinLockUnlock: 解锁
使用
@IBAction func ticketSale() {

        osspinlock = OSSpinLock()

        tickets = 30

        let queue = DispatchQueue.global()

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

        queue.async {

            for _ in 0..<10 {

                self.sellTicket()

            }

        }

    }

    //卖票

    func sellTicket() {

        OSSpinLockLock(&osspinlock)

        var oldTicket = tickets

        sleep(UInt32(0.2))

         oldTicket -= 1

         tickets = oldTicket

        print("还剩\(tickets)张票 ---- \(Thread.current)")

        OSSpinLockUnlock(&osspinlock)

    }

截屏2023-08-04 11.18.01.png

可以看到,最终的余票数量已经是正确的了,这里要注意的是osspinlock需要做成全局变量或者属性,多个线程要用这同一把锁去加锁和解锁,如果每个线程各自生成锁,则达不到要加锁的目的了

那么自旋锁是怎么样做到加锁保证线程安全的呢? 先来介绍下让线程阻塞的两种方法:

  • 忙等:也就是自旋锁的原理,它本质上就是个while循环,不停地去判断加锁条件,自旋锁没有让线程真正的阻塞,只是将线程处在while循环中,系统CPU还是会不停地分配资源来处理while循环指令。
  • 真正阻塞线程: 这是让线程休眠,类似于Runloop里的match_msg() 实现的效果,它借助系统内核指令,让线程真正停下来处于休眠状态,系统的CPU不再分配资源给线程,也不会再执行任何指令。系统内核用的是symcall指令来让线程进入休眠

它的原理就是,自旋锁在加锁失败时,让线程处于忙等状态,让线程停留在临界区之外,一旦加锁成功,就可以进入临界区对资源进行操作。

截屏2023-08-04 11.03.02.png

通过这个可以看到,苹果在iOS10之后就弃用了OSSpinLock,官方建议用 os_unfair_lock来代替,那么为什么要弃用呢?因为在iOS10之后线程可以设置优先级,在优先级配置下,可以产生优先级反转,使自旋锁卡住,自旋锁本身已经不再安全。

2. os_unfair_lock

os_unfair_lock 是苹果官方推荐的,自iOS10之后用来替代 OSSpinLock 的一种锁

  • os_unfair_lock_trylock: 尝试加锁,加锁成功返回true,继续执行。加锁失败,则返回false,不会阻塞线程。
  • os_unfair_lock_lock: 加锁,加锁失败,阻塞线程继续等待。加锁成功,继续执行。
  • os_unfair_lock_unlock : 解锁

使用:

//卖票

    func sellTicket() {

        os_unfair_lock_lock(&unfairlock)

        var oldTicket = tickets

        sleep(UInt32(0.2))

         oldTicket -= 1

         tickets = oldTicket

        print("还剩\(tickets)张票 ---- \(Thread.current)")

        os_unfair_lock_unlock(&unfairlock)

    }

打印结果和OSSpinLock一样,os_unfair_lock摒弃了OSSpinLock的while循环实现的忙等状态,而是采用了真正让线程休眠,从而避免了优先级反转问题。

3. pthread_mutex

pthread_mutexpthread跨平台的一种解决方案,mutex 为互斥锁,等待锁的线程会处于休眠状态。 互斥锁的初始化比较麻烦,主要为以下方式:

  1. var ticketMutexLock = pthread_mutex_t()
  2. 初始化属性:
var attr = pthread_mutexattr_t()
pthread_mutexattr_init(&attr)
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL)
  1. 初始化锁:pthread_mutex_init(&ticketMutexLock, &attr)

关于互斥锁的使用,主要提供了以下方法:

  1. 尝试加锁:pthread_mutex_trylock(&ticketMutexLock)
  2. 加锁:pthread_mutex_lock(&ticketMutexLock)
  3. 解锁:pthread_mutex_unlock(&ticketMutexLock)
  4. 销毁相关资源:pthread_mutexattr_destory(&attr), pthread_mutex_destory(&ticketMutexLock)

使用方式如下:

截屏2023-08-04 14.57.18.png

要注意,在析构函数中要将锁进行销毁释放掉 在初始化属性中,第二个参数有以下几种方式:

截屏2023-08-04 14.58.56.png

PTHREAD_MUTEX_DEFAULT = PTHREAD_MUTEX_NORMAL,代表普通的互斥锁 PTHREAD_MUTEX_ERRORCHECK 代表检查错误锁 PTHREAD_MUTEX_RECURSIVE 代表递归互斥锁

互斥锁的底层原理实现也是通过阻塞线程,等待锁的线程处于休眠状态,CPU不再给等待的线程分配资源,和上面讲到的os_unfair_lock原理类似,都是通过内核调用symcall方法来休眠线程,通过这个对比也能推测出,os_unfair_lock实际上也可以归属于互斥锁

3.1 递归互斥锁

截屏2023-08-04 15.10.24.png

如图所示,如果是上述场景,方法1里面嵌套方法2,正常调用时,输出应该为:

截屏2023-08-04 15.11.14.png

若要对上述场景保证线程安全,先用普通互斥锁添加锁试下

截屏2023-08-04 15.12.47.png

结果打印如下:

截屏2023-08-04 15.13.16.png

和预想中的不一样,如果懂得锁机制便会明白,图中所示的rsmText2中加锁失败,需要等待rsmText1中的锁释放后才可加锁,所以rsmText2方法开始等待并阻塞线程,程序无法再执行下去,那么rsmText1中锁释放的逻辑就无法执行,就这样造成了死锁,所以只能打印rsmText1中的输出内容。 解决这个问题,只需要给两个方法用两个不同的锁对象进行加锁就可以了,但是如果是针对于同一个方法递归调用,那么就无法通过不同的对象去加锁,这时候应该怎么办呢?递归互斥锁就该用上了。

截屏2023-08-04 15.19.22.png

截屏2023-08-04 15.19.44.png

截屏2023-08-04 15.19.58.png

如上,已经可以正常调用并加锁 那么递归锁是如何避免死锁的呢?简而言之就是允许对同一个对象进行重复加锁,重复解锁,加锁和解锁的次数相等,调用结束时所有的锁都会被解开

3.2 互斥锁条件 pthread_cond_t

互斥锁条件所用到的常见方法如下:

  1. 定义一个锁: var condMutexLock = pthread_mutex_t()
  2. 初始化锁对象:pthread_mutex_init(&condMutexLock)
  3. 定义条件对象:var condMutex = pthread_cond_t()
  4. 初始化条件对象:pthread_cond_init(&condMutex, nil)
  5. 等待条件:pthread_cond_wait(&condMutex, &condMutexLock) 等待过程中会阻塞线程,知道有激活条件的信号发出,才会继续执行
  6. 激活一个等待该条件的线程:pthread_cond_signal(&condMutex)
  7. 激活所有等待该条件的线程pthread_cond_broadcast(&condMutex)
  8. 解锁:pthread_mutex_unlock(&condMutexLock)
  9. 销毁锁对象和销毁条件对象:pthread_mutex_destroy(&condMutexLock) pthread_cond_destroy(&condMutex)

下面设计一个场景:

  • 在一个线程里对dataArr做remove操作,另一个线程里做add操作
  • dataArr为0时,不能进行删除操作
@IBAction func mutexCondTest(_ sender: Any) {

        initMutextCond()

    }

    func initMutextCond() {

        //初始化属性

        var attr = pthread_mutexattr_t()

        pthread_mutexattr_init(&attr)

        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL)

        //初始化锁

        pthread_mutex_init(&condMutexLock, &attr)

        //释放属性

        pthread_mutexattr_destroy(&attr)

        //初始化cond

        pthread_cond_init(&condMutex, nil)

        _testDataArr()

        

    }

    func _testDataArr() {

        let threadRemove = Thread(target: self, selector: #selector(_remove), object: nil)

        threadRemove.name = "remove 线程"

        threadRemove.start()

        

        sleep(UInt32(1))

        let threadAdd = Thread(target: self, selector: #selector(_add), object: nil)

        threadAdd.name = "add 线程"

        threadAdd.start()

        

    }

    @objc func _add() {

        //加锁

        pthread_mutex_lock(&condMutexLock)

        print("add 加锁成功---->\(Thread.current.name!)开始")

        sleep(UInt32(2))

        dataArr.append("test")

        print("add成功,发送条件信号 ------ 数组元素个数为\(dataArr.count)")

        pthread_cond_signal(&condMutex)

        //解锁

        pthread_mutex_unlock(&condMutexLock)

        print("解锁成功,\(Thread.current.name!)线程结束")

    }

    @objc func _remove() {

        //加锁

        pthread_mutex_lock(&condMutexLock)

        print("remove 加锁成功,\(Thread.current.name!)线程开启")

        if(dataArr.count == 0) {

            print("数组内没有元素,开始等待,数组元素为\(dataArr.count)")

            pthread_cond_wait(&condMutex, &condMutexLock)

            print("接收到条件更新信号,dataArr元素个数为\(dataArr.count),继续向下执行")

        }

        dataArr.removeLast()

        print("remove成功,dataArr数组元素个数为\(dataArr.count)")

        

        //解锁

        pthread_mutex_unlock(&condMutexLock)

        print("remove解锁成功,\(Thread.current.name!)线程结束")

    }

    

    deinit {

//        pthread_mutex_destroy(&ticketMutexLock)

        pthread_mutex_destroy(&condMutexLock)

        pthread_cond_destroy(&condMutex)

    }

输出结果为:

截屏2023-08-04 16.34.19.png

从打印结果来看,如果不满足条件时进行条件等待 pthread_cond_wati,remove 线程是解锁,此时线程是休眠状态,然后等待的add 线程进行加锁成功,处理add的逻辑。

当add 操作完毕时,通过 pthread_cond_signal发出信号,remove线程收到信号后被唤醒,然后remove线程会等待add线程解锁后,再进行加锁处理后续的逻辑.

整个过程中一共用到了三次加锁,三次解锁,这种锁可以处理线程依赖的场景.

4. NSLock, NSRecursiveLock, NSCondition

上文中提到了mutex普通互斥锁 mutex递归互斥锁mutext条件互斥锁,这几种锁都是基于C语言的API,苹果在此基础上做了面向对象的封装,分别对应如下:

  • NSLock 封装了 pthread_mutex_t的 attr类型为 PTHREAD_MUTEX_DEFAULT 或者 PTHREAD_MUTEX_NORMAL 普通锁
  • NSRecursiveLock 封装了 pthread_mutex 的 attr类型为PTHREAD_MUTEX_RECURSIVE递归锁
  • NSCondition 封装了 pthread_mutex_tpthread_cond_t

底层实现和 pthread_mutex_t一样,这里只看下使用方式即可:

4.1 NSLock
//普通锁 
let lock = NSLock() 
lock.lock()
lock.unlock()
4.2 NSRecursiveLock
let lock = NSRecursiveLock()
lock.lock()
lock.unlock()
4.3 NSCondition
let condition = NSCondition()
condition.lock()
condition.wait()
condition.signal()//condition.broadcast()
condition.unlock()
4.4 NSConditionLock

这个是NSCondition 的进一步封装,该锁允许我们在锁中设定条件具体条件值,有了这个功能,我们可以更加方便的多条线程的依赖关系和前后执行顺序

下面用一个场景来模拟下顺序控制的功能,有三条线程执行A,B,C三个方法,要求按A,C,B的顺序执行

@IBAction func conditionLockTest(_ sender: Any) {

       let threadA = Thread(target: self, selector: #selector(A), object: nil)

        threadA.name = "ThreadA"

        threadA.start()

       let threadB = Thread(target: self, selector: #selector(B), object: nil)

        threadB.name = "ThreadB"

        threadB.start()

       let threadC = Thread(target: self, selector: #selector(C), object: nil)

        threadC.name = "ThreadC"

        threadC.start()

    }

    @objc func A() {

        conditionLock.lock()

        print("A")

        sleep(UInt32(1))

        conditionLock.unlock(withCondition: 3)

    }

    @objc func B() {

        conditionLock.lock(whenCondition: 2)

        print("B")

        sleep(UInt32(1))

        conditionLock.unlock()

    }

    @objc func C() {

        conditionLock.lock(whenCondition: 3)

        print("C")

        conditionLock.unlock(withCondition: 2)

    }

输出结果为:

A

C

B

5. dispatch_semaphore

信号量 的初始值可以用来控制线程并发访问的最大数量,初始值为1,表示同时允许一条线程访问资源,这样可以达到线程同步的目的

  • 创建信号量: dispatch_semaphore_create(value)

  • 等待:dispatch_semaphore_wait(semaphore, 等待时间) 信号量的值 <= 0,线程就休眠等待,直到信号量 > 0,如果信号量的值 > 0,则就将信号量的值递减1,继续执行下面的程序

  • 信号量值+1: dispatch_semaphore_signal(semaphore)