iOS 多线程总结 - 线程安全
1. 多线程时间分片模式:
多数现代操作系统使用时间分片模式来管理线程的,它会将CPU运行时间分割成一个个时间片,一般在10-100毫秒之间不等,当线程被分配到执行时间后,系统将会将该线程的堆栈以及寄存器加载到CPU,并将旧线程的堆栈和寄存器数据保存起来,这就是所谓的上下文切换,这个切换也是需要耗费时间的,如果将时间片分割得太短就会导致过度频繁的上下文切换,从而导致大量的CPU时间浪费在切换上。这也是自旋锁存在的原因,这个在后面会详细介绍。
今天介绍的主题是线程安全,之前我们说过同一进程的线程之间是共享资源的,并且多线程是并发执行的,在这种并发环境下资源共享带来的一个问题就是资源竞争,竞争的资源可以是:锁、网络连接、通知事件,磁盘、带宽,以及一切可以被称作“资源”的东西,如果没有对共享资源进行保护,就会导致多个线程对资源进行修改,从而导致资源状态不确定,也就是我们所说的数据竞态。
但是并不是所有情况下多线程访问共享资源都会存在这些问题。
以下这些情况就不存在线程安全的情况:
- 多线程串行访问共享资源,这也是很多时候解决数据竞态的一种方案,将访问资源的操作串行化。
- 多线程并行情况下但是这些线程都是访问共享资源而不去修改共享资源,比如多个线程同时读一个文件。
所以发生数据竞态的条件有两个:
1. 至少有两个线程同时访问同一个资源 |
2. 基础概念
- 临界区:指的是一块对公共资源进行访问的代码
- 竞态条件:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件
- 死锁:是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去
死锁发生的条件:
- 互斥条件:线程对资源的访问是排他性的,如果一个线程占用了某个资源,那么其他线程等待,直到锁被释放。
- 不可剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。
- 保持和请求条件:线程T1至少已经保持了一个资源R1占用,但又提出对另一个资源R2请求,而此时,资源R2被其他线程T2占用,于是该线程T1也必须等待,但又对自己保持的资源R1不释放。
- 环路等待条件:在死锁发生时,必然存在一个“线程-资源环形链”,即:{p0,p1,p2,…pn},进程p0(或线程)等待p1占用的资源,p1等待p2占用的资源,pn等待p0占用的资源。
资源饥饿: 当一个线程一直无法得到自己的资源而一直无法进行后续的操作时,我们称这个线程会饥饿而死。
优先级反转:
优先级反转是在高优级(假设为A)的任务要访问一个被低优先级任务(假设为C)占有的资源时被阻塞.
而此时又有优先级高于占有资源的任务(C),而低于被阻塞的任务(A)的优先级的中间优先级任务(假设为B)进入时,这时候,占有资源的任务(C)就被挂起(占有的资源仍为它占有),因为占有资源的任务优先级很低,所以,它可能一直被另外的任务挂起.而它占有的资源也就一直不能释放,这样,引起任务A一直没办法执行.而比它优先低的任务却可以执行.
解决方案:
- 优先级继承:将低优先级任务的优先级提升到等待它所占有的资源的最高优先级任务的优先级.当高优先级任务由于等待资源而被阻塞时,此时资源的拥有者的优先级将会自动被提升.
- 优先级天花板:将申请某资源的任务的优先级提升到可能访问该资源的所有任务中最高优先级任务的优先级
两者区别:
优先级继承,只有当占有资源的低优先级的任务被阻塞时,才会提高占有资源任务的优先级,而优先级天花板,不论是否发生阻塞都提升.
- 原子操作: 一条不可打断的操作,在单处理器环境下,一条汇编指令是原子操作,但一句高级语言的代码却不是原子的,因为它最终是由多条汇编语言完成
- 可重入:当子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的
- 线程安全:指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
3. iOS多线程开发中的常用锁:
3.1 互斥锁
互斥锁在出现锁的争夺时,未获得锁的线程会主动让出时间片,阻塞线程并睡眠,CPU会通过上下文切换,让其它线程继续运行。互斥锁用于保证某个资源只允许一个线程访问。
3.1.1 NSLock
NSLock 内部封装了一个 pthread_mutex,属性为 PTHREAD_MUTEX_ERRORCHECK, 它比pthread_mutex多了错误提示,也正式这个原因它比pthread_mutex性能上要慢,但是由于它在内部使用了缓存机制,所以性能上不会相差很多。但是它使用的时候需要注意的是加锁和解锁需要成对出现,并且在解锁之前不可以进行再次加锁。否则会造成死锁。
NSLock *lock = [NSLock alloc] init]; |
NSLock死锁的例子:
- (void)recursiveFunc:(NSInteger)value { |
如果不清楚的话展开就应该很容易明白了
[self.lock lock]; |
最后再强调下在使用NSLock的时候注意加解锁需要在统一线程中,并且在解锁之前不能重复加锁。
3.2 递归锁
3.2.1 NSRecursiveLock
递归锁在被同一线程重复获取时不会产生死锁。它会记录上锁和解锁的次数,当二者平衡的时候,才会释放锁,其它线程才可以上锁成功
@property (nonatomic, strong) NSRecursiveLock *recursiveLock; |
3.2.2 synchronized
@synchronized 结构在工作时为传入的对象分配了一个递归锁。它需要使用一个唯一的标识用来区分保护锁。
|
当调用 objc_sync_enter(obj) 时,它用 obj 内存地址的哈希值查找合适的 SyncData(包含传入对象和一个递归锁的结构体),然后将其上锁。当你调用 objc_sync_exit(obj) 时,它查找合适的 SyncData 并将其解锁。
@synchronized(object)指令使用的 object 为该锁的唯一标识,只有当标识相同时,才满足互斥
优点:使用起来十分简单不需要在代码中显式的创建锁对象,便可以实现锁的机制,并且不用担心忘记解锁的情况出现。同时synchronized不需要像NSLock一样需要考虑在加解锁时需要在同一线程中的问题,也不需要考虑同一个线程中连续加锁的问题。 |
注意:如果在 @sychronized(object){} 内部object 被释放或被设为nil,没有问题,但如果 object 一开始就是nil,则失去了锁的功能。
- (void)setIntegerValue:(NSInteger)intValue { |
3.3 自旋锁:
自旋锁与互斥锁有点类似,只是自旋锁被某线程占用时,其他线程不会进入睡眠状态等待,而是一直轮询查询直到锁被释放。
由于不涉及用户态与内核态之间的切换,它的效率远远高于互斥锁。
但是自旋锁也有很明显的不足:
- 自旋锁一直占用CPU,在未获得锁的情况下会占用着CPU一直运行,如果不能在很短的时间内获得锁,这无疑会使CPU效率降低。
- 自旋锁可能会引起优先级反转问题。如果一个低优先级的线程获得锁并访问共享资源,这时一个高优先级的线程也尝试获得这个锁,自旋锁会处于忙等状态从而占用大量 CPU。此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。
所以一般自旋锁只有在内核可抢占式比较适用,在单CPU且不可抢占式的内核下自旋锁只适用于锁使用者保持锁时间比较短的情况。
3.3.1 OSSpinLock
// 初始化 |
目前OSSpinLock存在优先级反转的问题,在使用的时候需要十分注意。
不再安全的 OSSpinLock
3.3.2 os_unfair_lock
os_unfair_lock是替代OSSpinLock的产物,iOS 10.+ 之后添加的,也是属于忙等锁。
#import <os/lock.h> |
3.4 信号量
加锁时会把信号量的值减一,并判断是否大于零。如果大于零,立刻执行。如果等于零的时候将会等待,在资源使用结束的时候释放信号量让信号量增加1。并唤醒等待的线程。
信号量可以有更多的取值空间,用来实现更加复杂的同步,而不单单是线程间互斥。
3.4.1 dispatch_semaphore
dispatch_semaphore_t signal = dispatch_semaphore_create(1); |
3.5 条件锁
条件锁一般常用于生产者–消费者模式
3.5.1 NSCondition
NSCondition同样实现了NSLocking协议,可以当做NSLock来使用解决线程同步问题,用法完全一样。但是性能相对更差点,除了lock 和 unlock,NSCondition提供了更高级的用法wait/signal/broadcast:wait 进入等待状态,当其它线程中的该锁执行signal 或者 broadcast方法时,线程被唤醒,继续运行之后的方法。其中 signal 和 broadcast 方法的区别在于,signal 只是一个信号量,只能唤醒一个等待的线程,想唤醒多个就得多次调用,而 broadcast 可以唤醒所有在等待的线程。
@property (nonatomic, strong) NSCondition *condition; |
3.5.2 NSConditionLock
NSConditionLock 借助 NSCondition 来实现,内部持有一个 NSCondition 对象,以及 _condition_value 属性
lockWhenCondition:方法是当condition参数与当前condition相等时才可加锁
unlockWithCondition:方法是解锁之后修改 Condition 的值
// 设置条件 |
3.6 读写锁
读写锁把对共享资源的访问者划分成读和写,在多处理器系统中,它允许同时有多个读来访问共享资源,最大可能的读者数为实际的逻辑CPU数。但是写操作是排他性的。
所以如下情况可以并发进行:
* 多个读,没有写操作 |
3.6.1 dispatch_barrier_async / dispatch_barrier_sync
具体用法见iOS多线程总结 基本用法
- 共同点:1、等待在它前面插入队列的任务先执行完;2、等待他们自己的任务执行完再执行后面的任务。
- 不同点:1、dispatch_barrier_sync将自己的任务插入到队列的时候,需要等待自己的任务结束之后才会继续插入被写在它后面的任务,然后执行它们;2、dispatch_barrier_async将自己的任务插入到队列之后,不会等待自己的任务结束,它会继续把后面的任务插入队列,然后等待自己的任务结束后才执行后面的任务。
3.6.1 pthread_rwlock
#import <pthread.h> |
3.7 atomic
atomic用于保证属性setter、getter的原子性操作,在getter和setter内部加了线程同步的锁,但是它并不能保证使用属性的过程是线程安全的
4. 其它保证线程安全的方式
使用异步串行队列来将访问资源的操作串行化