注册
iOS

iOS 线程安全和锁机制

一、线程安全场景


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


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


1. 购票案例


cf2e38ff0ac9da7c84b06a838dff670d.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,下面看下打印结果:


5d22d3b66bf566e870692ed3d7f3de56.png


可以看到打印票数不为0


2. 存钱取钱案例


先用个图说明


f89df42fe239164716250f1e3120fc37.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


77c026e08c31ad791026a845b7c1ca3e.png


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



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



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


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

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

1. OSSpinLock 自旋锁


09cab0e0fbebc0be22988fc2f9912710.png


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


4ba575c1cd2e579fea026cb9ada7af05.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)

    }

eb4c38d71c462cac6b419710469a54e5.png


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


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

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

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


8b3456b8b52052ece90e8151a23f272e.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)

3. 初始化锁: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)

使用方式如下:


a8e243bcdf679e6b4677edc6fc3c4fe6.png


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


59d8bd278bf157850c55f138f1506b5d.png


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


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


3.1 递归互斥锁

4f0714afbb9f6e155d7acce419ff99da.png


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


c773c82e98760625c22bcba07510c846.png


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


a9fa8d68915aca7b88d18ecb9c13a77f.png


结果打印如下:


333ae961b8529e490922b844d1dfb294.png


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


dca2bbd453cc6ebd932cfbfae2b9a31f.png


ac1849e9345bb9adab1613a6e8bc47bc.png


b8d81149b5b074942728a913bac07873.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)

    }

输出结果为:


880e2dda7557067bccc55361d2b28171.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_t 和 pthread_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)


作者:君陌笑
链接:https://juejin.cn/post/7263350491610726460
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册