只要提到了多线程就应该想到线程安全,那么怎么做才能做到在多个线程中保证安全呢?
这篇文章主要讲解线程安全。
线程安全
线程安全是什么呢?摘抄一段百度百科的一段话
线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。
为什么需要线程安全
ATM肯定用过,你要是边取钱,边存钱,会出问题吗?当你取钱的时候,正在取,结果有人汇款正好到账,本来1000块取了100剩下900,结果到账200,1000+200=1200,因为你取的时候,还没取完,汇款到账了结果数字又加上去了。你取的钱跑哪里去了,这里就需要取钱的时候不能写入数据,就是汇款需要在你取钱完成之后再汇款,不能同时进行。
那么在iOS中,锁是如何使用的呢?
自旋锁 OS_SPINLOCK
什么是优先级反转
简单从字面上来说,就是低优先级的任务先于高优先级的任务执行了,优先级搞反了。那在什么情况下会生这种情况呢?
假设三个任务准备执行,A,B,C,优先级依次是A>B>C;
首先:C处于运行状态,获得CPU正在执行,同时占有了某种资源;
其次:A进入就绪状态,因为优先级比C高,所以获得CPU,A转为运行状态;C进入就绪状态;
第三:执行过程中需要使用资源,而这个资源又被等待中的C占有的,于是A进入阻塞状态,C回到运行状态;
第四:此时B进入就绪状态,因为优先级比C高,B获得CPU,进入运行状态;C又回到就绪状态;
第五:如果这时又出现B2,B3等任务,他们的优先级比C高,但比A低,那么就会出现高优先级任务的A不能执行,反而低优先级的B,B2,B3等任务可以执行的奇怪现象,而这就是优先反转。
OS_SPINLOCK
叫做自旋锁
,等待锁的进程会处于忙等(busy-wait)状态,一直占用着CPU资源,目前已经不安全,可能会出现优先级翻转问题。
OS_SPINLOCK
API
1 | //初始化 一般是0,或者直接数字0也是ok的。 |
OSSpinLock
简单实现12306如何卖票
1 | //基类实现的卖票 |
汇编分析
1 | for (NSInteger i = 0; i < 5; i ++) { |
到了断点进入Debug->Debug WorkFlow ->Always Show Disassembly
,到了汇编界面,在LLDB
输入stepi
,然后一直按enter
,一直重复执行上句命令,直到进入了循环,就是类似下列的三行,发现ja
跳转到地址0x103f3d0f9
,每次执行到ja
总是跳转到0x103f3d0f9
,直到线程睡眠结束。
1 | -> 0x103f3d0f9 <+241>: movq %rcx, (%r8) |
可以通过汇编分析了解到自旋锁
是真的忙等
,闲不住的锁。
os_unfair_lock
os_unfair_lock
被系统定义为低级锁,一般低级锁都是闲的时候在睡眠,在等待的时候被内核唤醒,目的是替换已弃用的OSSpinLock
,而且必须使用OS_UNFAIR_LOCK_INIT
来初始化,加锁和解锁必须在相同的线程,否则会中断进程,使用该锁需要系统在__IOS_AVAILABLE(10.0)
,锁的数据结构是一个结构体
1 | OS_UNFAIR_LOCK_AVAILABILITY |
os_unfair_lock
使用非常简单,只需要在任务前加锁,任务后解锁即可。
1 | @interface FYOSUnfairLockDemo : FYBaseDemo |
汇编分析
LLDB
中命令stepi
遇到函数会进入到函数,nexti
会跳过函数。我们将断点打到添加锁的位置
1 | - (void)__saleTicket{ |
执行si
,一直enter
,最终是停止该位子,模拟器缺跳出来了,再enter
也没用了,因为线程在睡眠了。syscall
是调用系统函数的命令。
1 | libsystem_kernel.dylib`__ulock_wait: |
互斥锁 pthread_mutex_t
mutex
叫互斥锁,等待锁的线程会处于休眠状态。
1 | -(void)dealloc{ |
互斥锁有三个类型
1 | /* |
当我们这样子函数调用函数会出现死锁的问题,这是怎么出现的呢?第一把锁是锁住状态,然后进入第二个函数,锁在锁住状态,在等待,但是这把锁需要向后执行才会解锁,到时无限期的等待。
1 | - (void)otherTest{ |
上面这个需求需要使用两把锁,或者使用递归锁来解决问题。
1 | - (void)otherTest{ |
从使用2把锁是可以解决这个问题的。
递归锁是什么锁呢?允许同一个线程对一把锁重复加锁。
NSLock、NSRecursiveLosk
NSLock
是对mutex
普通锁的封装
使用(LLDB) si
可以跟踪[myLock lock];
的内部函数最终是pthread_mutex_lock
1 | Foundation`-[NSLock lock]: |
NSLock API
大全
1 | //协议NSLocking |
用法也很简单
1 | @interface FYNSLock(){ |
NSRecursiveLock
也是对mutex递归锁
的封装,API
跟NSLock
基本一致
1 | - (BOOL)tryLock;//尝试加锁 |
递归锁可以对相同的线程进行反复加锁
1 | @implementation FYRecursiveLockDemo |
NSCondition 条件
1 | - (void)wait;//等待 |
NSCondition
是对mutex
和cond
的封装
1 | - (instancetype)init{ |
可以看到时间上差了1秒,正好是我们设定的sleep(1);
。优点是可以让线程之间形成依赖,缺点是没有明确的条件。
NSConditionLock 可以实现线程依赖的锁
NSConditionLock
是可以实现多个子线程进行线程间的依赖,A依赖于B执行完成,B依赖于C执行完毕则可以使用NSConditionLock
来解决问题。
首先看下API
1 | @property (readonly) NSInteger condition;//条件值 |
条件锁的使用,在lockWhenCondition:(NSInteger)condition
的条件到达的时候才能进行正常的加锁和unlockWithCondition:(NSInteger)condition
解锁,否则会阻塞线程。
1 | - (void)otherTest{ |
当con = 1
进行test1
加锁和执行任务A
,任务A
执行完毕,进行解锁,并把值2赋值给lock
,这是当con = 2
的锁开始加锁,进入任务B
,开始执行任务B
,当任务B
执行完毕,进行解锁并赋值为3,然后con=3
的锁进行加锁,解锁并赋值4来进行线程之间的依赖。
dispatch_queue 特殊的锁
其实直接使用GCD的串行队列,也是可以实现线程同步的。串行队列其实就是线程的任务在队列中按照顺序执行,达到了锁的目的。
1 | @interface FYSerialQueueDemo(){ |
dispatch_semaphore 信号量控制并发数量
当我们有大量任务需要并发执行,而且同时最大并发量为5个线程,这样子又该如何控制呢?dispatch_semaphore
信号量正好可以满足我们的需求。dispatch_semaphore
可以控制并发线程的数量,当设置为1时,可以作为同步锁来用,设置多个的时候,就是异步并发队列。
1 | //初始化信号量 值为2,就是最多允许同时2个线程执行 |
一次最多2个线程同时执行任务,暂停时间是2s。
使用信号量实现线程最大并发锁,
同时只有2个线程执行的。
1 | - (instancetype)init{ |
@synchronized
@synchronized(id obj){}
锁的是对象obj
,使用该锁的时候,底层是对象计算出来的值作为key
,生成一把锁,不同的资源的读写可以使用不同obj
作为锁对象。
1 | - (void)__saleTicket{ |
atmoic 原子操作
给属性添加atmoic
修饰,可以保证属性的setter
和getter
都是原子性操作,也就保证了setter
和getter
的内部是线程同步的。
原子操作是最终调用了static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) objc-accessors.mm 48行
,我们进入到函数内部
1 | //设置属性原子操作 |
从源码了解到设置属性读取是self
+属性的偏移量,当copy
或mutableCopy
会调用到[newValue copyWithZone:nil]
或[newValue mutableCopyWithZone:nil]
,如果新旧值相等则不进行操作,非原子操作直接赋值,原子操作则获取spinlock_t& slotlock = PropertyLocks[slot]
进行加锁、赋值、解锁操作。而且PropertyLocks
是一个类,类有一个数组属性,使用*p
计算出来的值作为key
。
我们提取出来关键代码
1 | //原子操作 加锁 |
使用自旋锁对赋值操作进行加锁,保证了setter()
方法的安全性
1 | //原子操作 加锁 ->自旋锁 |
取值之前进行加锁,取值之后进行解锁,保证了getter()
方法的安全。
由上面得知atmoic
仅仅是对方法setter()
和getter()
安全,对成员变量不保证安全,对于属性的读写一般使用nonatomic
,性能好,atomic
读取频率高的时候会导致线程都在排队,浪费CPU时间。
大概使用者几种锁分别对卖票功能进行了性能测试,
性能分别1万次、100万次、1000万次锁花费的时间对比,单位是秒。(仅供参考,不同环境时间略有差异)
锁类型 | 1万次 | 100万次 | 1000万次 |
---|---|---|---|
pthread_mutex_t | 0.000309 | 0.027238 | 0.284714 |
os_unfair_lock | 0.000274 | 0.028266 | 0.285685 |
OSSpinLock | 0.030688 | 0.410067 | 0.437702 |
NSCondition | 0.005067 | 0.323492 | 1.078636 |
NSLock | 0.038692 | 0.151601 | 1.322062 |
NSRecursiveLock | 0.007973 | 0.151601 | 1.673409 |
@synchronized | 0.008953 | 0.640234 | 2.790291 |
NSConditionLock | 0.229148 | 5.325272 | 10.681123 |
semaphore | 0.094267 | 0.415351 | 24.699100 |
SerialQueue | 0.213386 | 9.058581 | 50.820202 |
建议
平时我们简单使用的话没有很大的区别,还是推荐使用NSLock
和信号量,最简单的是@synchronized
,不用声明和初始化,直接拿来就用。
自旋锁、互斥锁比较
自旋锁和互斥锁各有优劣,代码执行频率高,CPU充足,可以使用互斥锁,频率低,代码复杂则需要互斥锁。
自旋锁
- 自旋锁在等待时间比较短的时候比较合适
- 临界区代码经常被调用,但竞争很少发生
- CPU不紧张
- 多核处理器
互斥锁
- 预计线程等待时间比较长
- 单核处理器
- 临界区IO操作
- 临界区代码比较多、复杂,或者循环量大
- 临界区竞争非常激烈
锁的应用
简单读写锁
一个简单的读写锁,读写互斥即可,我们使用信号量,值设定为1.同时只能一个线程来操作文件,读写互斥。
1 | - (void)viewDidLoad { |
当读写都是一个线程来操作,会降低性能,当多个线程在读资源的时候,其实不需要同步操作的,有读没写,理论上说不用限制异步数量,写入的时候不能读,才是真正限制线程性能的地方,读写锁具备以下特点
- 同一时间,只能有1个线程进行写操作
- 同一时间,允许有多个线程进行读的操作
- 同一时间,不允许读写操作同时进行
典型的多读单写
,经常用于文件等数据的读写操作,我们实现2种
读写锁 pthread_rwlock
这是有c语言封装的读写锁
1 | //初始化读写锁 |
pthread_rwlock_t
使用很简单,只需要在读之前使用pthread_rwlock_rdlock
,读完解锁pthread_rwlock_unlock
,写入前需要加锁pthread_rwlock_wrlock
,写入完成之后解锁pthread_rwlock_unlock
,任务都执行完了可以选择销毁pthread_rwlock_destroy
或者等待下次使用。
1 | @property (nonatomic,assign) pthread_rwlock_t rwlock; |
读文件会出现同一秒读多次,写文件同一秒只有一个。
异步栅栏调用 dispatch_barrier_async
栅栏大家都见过,为了分开一个地区而使用的,线程的栅栏函数是分开任务的执行顺序
操作 | 任务 | 任务 | 任务 |
---|---|---|---|
读 | A | B | |
读 | A | B | |
写 | C | ||
写 | C | ||
读 | A | ||
读 | A | B |
这个函数传入的并发队列必须是通过dispatch_queue_create
创建,如果传入的是一个串行的或者全局并发队列,这个函数便等同于dispatch_async
的效果。
1 | //初始化 异步队列 |
读文件会出现同一秒读多个,写文件同一秒只有一个。
读写任务都添加到异步队列rwqueue
中,使用栅栏函数dispatch_barrier_async
拦截一下,实现读写互斥,读可以异步无限读,写只能一个同步写的功能。
总结
- 普通线程锁本质就是同步执行
atomic
原子操作只限制setter
和getter
方法,不限制成员变量- 读写锁高性能可以使用
pthread_rwlock_t
和dispatch_barrier_async
参考资料
- 优先级反转
- iOS多线程:『GCD』详尽总结
- 小码哥视频
- 任务调度
- libdispatch
- iOS和OS多线程与内存管理
资料下载
- 学习资料下载git
- demo code git
- runtime可运行的源码git
最怕一生碌碌无为,还安慰自己平凡可贵。
广告时间