前言
对于iOS中各种锁的学习总结,供日后查阅
引子
日常开发中,@property (nonatomic, strong) *foo
是我们不厌其烦的使用频率最高的声明方式,也很清楚atomic
和nonatomic
属性的区别,这里再复习一下这两个关键字:
atomic
:原子性,这个属性是默认的,通过在setter
、getter
中加锁保证数据的读写安全nonatomic
:非原子性,就是不加锁。优点是速度优于使用atomic
,大多数场景不会出现问题
作为编译器标识符,@property
的作用是帮助我们快速生成成员变量及其getter/setter方法,并通过属性关键字,帮助我们管理内存及安全等繁杂的事务,那么atomic
是如何帮助我们保证成员变量的读写安全呢?下面我们看一段代码:
1 | //@property(retain) UITextField *userName; |
我们可以很容易的看出,编译器是通过加锁,来保证当前成员变量_userName
的读写安全,不至于生成脏数据,这便是atomic
背后,编译器帮我们做的事情。事实上,如果深究下去编译器帮我们加了什么锁,其实并非@synchronized(object)
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换,影响锁的性能
为什么atomic
会做为默认属性,我们不难看出,苹果这么设计是想告诉我们,很多情况下,效率换安全是值得的
如何使用锁
下面一段简单代码,考虑一下输出结果
1 | - (void)unlockTest { |
书写这样一段代码,是想在不同线程中在改变变量后,使用这个变量
控制台输出:
1 | 2019-11-11 16:52:43.019128+0800 DiscoverLock_iOS[89763:11225442] Mike-Locked-JailBreaked |
这显然不是想要的结果,如何保证我们在不同线程中使用的变量,都是我们希望的值呢?答案之一,就是加锁
OC为我们提供了四种遵循NSLock
/NSCondtionLock
/NSRecursiveLock
/NSCondition
,满足面向对象编程的需求
1 | @protocol NSLocking |
加锁的基本流程: 【加锁】->【操作】->【解锁】
以上提到的4个类,均可以实现这个基础功能,下文中不再赘述
1 | - (void)lockedTest { |
控制台输出:
1 | DiscoverLock_iOS[90562:11303793] Mike-Locked |
1 | DiscoverLock_iOS[90562:11303793] Mike-JailBreaked |
这里的输出,结果不太一样,侧面说明了DISPATCH_QUEUE_PRIORITY
并不能保证线程的执行顺序,如果要明确执行顺序,属于线程同步的范畴,本文不展开讨论,只会在NSConditionLock部分简单示例如何使用该类做到同步
NSLock
- (BOOL)tryLock;
:尝试加锁,如果失败返回NO,不会阻塞线程- (BOOL)lockBeforeDate:(NSDate *)limit;
:指定时间前尝试加锁,如果失败返回NO,到时间前阻塞线程
示例代码:
1 | - (void)lockTest { |
控制台输出:
1 | 2019-11-11 19:54:08.807763+0800 DiscoverLock_iOS[92986:11465678] Mike-Locked |
通过上面示例代码输出可以看到,- (BOOL)tryLock;
并不会阻塞线程,在尝试加锁失败时,立即返回了NO,但是- (BOOL)lockBeforeDate:(NSDate *)limit;
则在时间到之前阻塞了线程操作,在等待相应时间后,返回了NO,并执行了下一句打印,很明显是在等待期间阻塞了线程
上面代码中用到的几个宏定义,建议以后使用锁时,尽量保持头脑清醒或者干脆定义一些便利方法,保证【上锁】-【解锁】的成对出现,避免线程阻塞或死锁的情况
1 | #define LOCK(...) \ |
NSConditionLock
- (instancetype)initWithCondition:(NSInteger)condition NS_DESIGNATED_INITIALIZER;
:便利构造方法,传入条件锁的初始值@property (readonly) NSInteger condition;
:当前条件锁的值- (void)lockWhenCondition:(NSInteger)condition;
:当锁的条件值与传入值相等时,执行接下来的操作,否则阻塞线程- (BOOL)tryLock;
:尝试加锁,如果失败返回NO,不会阻塞线程- (BOOL)tryLockWhenCondition:(NSInteger)condition;
:尝试加锁,当锁的条件值与传入值相等,则加锁成功,否则失败返回NO,不会阻塞线程- (void)unlockWithCondition:(NSInteger)condition;
:解锁操作,同时变更锁的条件值为传入值- (BOOL)lockBeforeDate:(NSDate *)limit;
:指定时间前尝试加锁,如果失败返回NO,到时间前阻塞线程- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;
:指定时间前尝试加锁,当锁的条件值与传入值相等,则加锁成功返回YES,否则失败返回NO,到时间前阻塞线程
NSConditionLock
和NSLock
方法类似,多了一个condition
属性,以及每个操作都多了一个关于condition属性的方法,- (void)lockWhenCondition:(NSInteger)condition;
只有condition参数与初始化时候的condition相等,lock才能正确进行加锁操作。而- (void)unlockWithCondition:(NSInteger)condition;
并不是当条件值符合条件时才解锁,而是解锁之后,修改当前锁的条件值
假如不使用condition相关的方法,NSConditionLock
同NSLock
并无二致
上文中我们提到了线程同步问题,这里一起看一下下面这段代码
1 | - (void)conditionLockUnordered { |
控制台输出:
1 | 2019-11-11 20:34:16.875479+0800 DiscoverLock_iOS[93895:11551560] >>> 2 -1--2--4--3- threadInfo:<NSThread: 0x600003905640>{number = 4, name = (null)}<<< |
依然是混乱状态,上文中NSLock
部分已经通过加锁,控制了读写的稳定性,那么如果我们想要按照标号依次执行,该如何操作?
熟悉GCD
的小伙伴会说这还不简单,dispatch_barrier
解千愁,当然这么写没问题,但是这里多说一嘴,dispatch_barrier
只能针对同一个并发队列起作用,注意正确初始化的姿势dispatch_queue_t thread = dispatch_queue_create("barrier", DISPATCH_QUEUE_CONCURRENT);
,而不是干啥都是一句dispatch_get_global_queue(0,0)
,如果使用Global_Queue,这个barrier就同普通的dispatch_async
没什么区别了
我们要是想在不同线程搞定顺序这个事儿,怎么办呢?这个时候NSConditionLock
自带的条件方法,便能帮你实现这个功能,具体看下面的示例代码
1 | - (void)conditionLockOrdered { |
控制台输出:
1 | 2019-11-11 20:53:58.237847+0800 DiscoverLock_iOS[94374:11586439] 🍺 |
可以看到,不同的线程,虽然被调度的时机不同,但是因为NSConditionLock
的存在,后续对数据具体的操作,我们预想的顺序得到了保证。这种用法笔者并认为在任务耗时较少的情况下没有明显问题的,但是假如存在长时间的耗时操作,还是建议使用dispatch_barrier
,因为这样不会占用过多资源
NSRecursiveLock
- (BOOL)tryLock;
:尝试加锁,如果失败返回NO,不会阻塞线程- (BOOL)lockBeforeDate:(NSDate *)limit;
:指定时间前尝试加锁,如果失败返回NO,到时间前阻塞线程
Api同NSLock
完全一样,区别在于NSRecursiveLock(递归锁)
可以在同一线程中重复加锁而不死锁,它会记录【上锁】和【解锁】的次数,当这两个值平衡时,才会释放锁,其他线程才可以上锁成功
先看下一段代码,会存在什么问题:
1 | @property (nonatomic, assign) NSInteger recursiveNum;// 5 |
控制台输出:
1 | 2019-11-11 21:27:13.451703+0800 DiscoverLock_iOS[95105:11645279] >>> 4 <<< |
同时存在两个线程,对已知的recursiveNum的值进行写操作,其中一个线程使用递归调用,对该值进行了操作,但是同时另一个线程改变了这个值,在不加锁的情况下,这种操作问题很多,如果递归中含有重要的逻辑处理,竞态可能导致整个逻辑执行完的结果大概率是错误的。
如何规避这种竞态导致的不必要的错误,首先我们想到的是加锁,但是如果递归加锁的话,线程会重复加锁,导致死锁。所以这时候必须使用递归锁来解决这个问题
1 | - (void)test_unrecursiveLock { |
控制台输出:
1 | 2019-11-11 21:34:44.422337+0800 DiscoverLock_iOS[95341:11655990] >>> 4 <<< |
虽然存在两种输出结果,但是我们的递归操作的逻辑,可以完全不受干扰,如果需要控制顺序,(敲黑板)要怎么做呢?
NSCondition
- (void)wait;
:当前线程立即进入休眠状态- (BOOL)waitUntilDate:(NSDate *)limit;
:当前线程立即进入休眠状态,limit时间后唤醒- (void)signal;
:唤醒wait后进入休眠的单条线程- (void)broadcast;
:唤醒wait后进入休眠的所有线程,调度
有些情况需要协调线程之间的执行。例如,一个线程可能需要等待其他线程返回结果,这个时候NSCondition
可能是个好选择
为了能体现NSCondition的作用,我们举一个可能并不是很恰当的生产者-消费者的例子:
我们现在有一条柔性生产线,限定每个批次只能生产3件商品,耗时6s,同时开放网络购买平台让大家抢购拼团,订单式销售,三人成团,现在有三位天选之子 Tom/Mike/Lily 从全球千万人中脱颖而出,成功成团。为了增强可玩性,活动是从开启的一刻起,同时开始生产和抢购,3件库存销售完成后,再次进行同时进行生产和抢购活动
代码示例如下:
1 | @interface Producer : NSObject |
部分控制台输出:
1 | 2019-11-12 17:04:03.662926+0800 DiscoverLock_iOS[7110:12246052] Mike-准备购买 |
基于多线程并发的工作原理,通过上面的部分打印结果,也很容易得到这个结论。由于不符合购买条件,Lily/Mike/Tom都只能选择wait
,这个时候,生产者获取到锁并执行生产代码,在生产完成后,broadcast
或者signal
告诉其他线程,可以唤醒线程并继续执行消费者相关代码。NSCondition
相较于NSConditionLock
的不同点在于他依赖的是外部值,能够满足更多复杂需求场景。
假如将上述代码中生产者的broadcast
替换成signal
后发现,在当前这种特定场景下,这两个方法的作用似乎并没有什么区别。而且感兴趣的同学,可以使用上述代码多运行几次,看看是否能够得出同笔者一样的猜测:
- NSCondition会自身通过队列管理协同任务的调度
- wait的任务依次入等待队列
- 未wait的任务根据获得锁的顺序依次入执行队列
- wait任务的等待队列会在执行队列执行完后依次执行并入执行队列
- 第一次调度顺序确定后,后续任务的执行,按照执行队列缓存依次出列执行
这里仅做猜想,具体实现可能并非如此,待大佬指点迷津或有机会鶸笔者自行研究
OSSpinLock
看了NSCondition
这么个复杂的东西,我们看点轻松的,OSSpinLock
是苹果在iOS10之前提供的自旋锁方案,但是存在优先级反转的问题,被苹果废弃,以前源码中使用OSSpinLock
的地方,都被苹果替换成了pthread_mutex
os_unfair_lock
os_unfair_lock
是iOS10以后新增的低级别加锁方案,本质是互斥锁,这里需要注意,目前很多文章认为他是作为替代OSSpinLock
的方案就是自旋锁是有问题的
void os_unfair_lock_lock(os_unfair_lock_t lock);
:加锁bool os_unfair_lock_trylock(os_unfair_lock_t lock);
:尝试加锁,成功返回true,失败返回falsevoid os_unfair_lock_unlock(os_unfair_lock_t lock);
:解锁void os_unfair_lock_assert_owner(os_unfair_lock_t lock);
:如果当前线程未持有指定的锁,则触发断言void os_unfair_lock_assert_not_owner(os_unfair_lock_t lock);
:如果当前线程持有指定的锁,则触发断言
各方法同常见的锁没太大差别,可以看下方法注释,只是需要注意一下初始化方式
1 | { |
@synchronize(object)
@synchronized(object)
指令使用传入的对象作为该锁的唯一标识,只有当标识相同时,才满足互斥@synchronized(object)
指令实现锁的优点就是我们不需要在代码中显式的创建锁对象,便可以实现锁的机制,而且不用担心忘记解锁
使用方法极其常见,不做示例了
dispatch_semaphore
dispatch_semaphore_t dispatch_semaphore_create(long value);
:创建信号量,传入初始值long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout);
:当信号<=0时,根据传入的时间阻塞线程;如果信号>0则不阻塞线程,并对信号-1处理long dispatch_semaphore_signal(dispatch_semaphore_t dsema);
:对信号+1处理
GCD
为我们提供的信号量也是常用的加锁方式,常见用法是初始化信号值为1
1 | { |
常规操作大家都知道,有常规操作,那么一定也有非常规操作,可以看一下AFNetwork
给我们的示范
1 | - (NSArray *)tasksForKeyPath:(NSString *)keyPath { |
在AFURLSessionManager
中,初始化使用dispatch_semaphore_create(0)
,在return tasks;
前调用dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
阻塞线程,待block将目标值赋值后,执行dispatch_semaphore_signal(semaphore);
,此时tasks已经有值,线程被唤醒后正常返回。很秀
pthread_mutex
C语言下的互斥锁方案,是
锁常用函数:
pthread_mutex_init
:动态初始化互斥量PTHREAD_MUTEX_INITIALIZER
:静态创建互斥量pthread_mutex_lock
:给一个互斥量加锁pthread_mutex_trylock
:加锁,如果失败不阻塞pthread_mutex_unlock
:解锁pthread_mutex_destroy
:销毁锁
参数配置函数:
pthread_mutexattr_init
:初始化参数pthread_mutexattr_settype
:设置类型pthread_mutexattr_setpshared
:设置作用域pthread_mutexattr_destroy
:销毁参数
条件常见函数:
pthread_cond_init
:动态初始化条件量PTHREAD_COND_INITIALIZER
:静态创建条件量pthread_cond_wait
:传入条件量及锁pthread_cond_signal
:唤醒单条线程并加锁pthread_cond_broadcast
:广播唤醒所有线程pthread_cond_destroy
:销毁条件
以上函数都是有返回值的,需要注意的是,若成功则返回0,否则返回错误编号,不是我们习惯中的成功YES失败NO
锁类型:
PTHREAD_MUTEX_NORMAL
:缺省值,这种类型的互斥锁不会自动检测死锁。如果一个线程试图对一个互斥锁重复锁定,将会引起这个线程的死锁。如果试图解锁一个由别的线程锁定的互斥锁会引发不可预料的结果。如果一个线程试图解锁已经被解锁的互斥锁也会引发不可预料的结果PTHREAD_MUTEX_ERRORCHECK
:这种类型的互斥锁会自动检测死锁。如果一个线程试图对一个互斥锁重复锁定,将会返回一个错误代码。如果试图解锁一个由别的线程锁定的互斥锁将会返回一个错误代码。如果一个线程试图解锁已经被解锁的互斥锁也将会返回一个错误代码PTHREAD_MUTEX_RECURSIVE
:如果一个线程对这种类型的互斥锁重复上锁,不会引起死锁,一个线程对这类互斥锁的多次重复上锁必须由这个线程来重复相同数量的解锁,这样才能解开这个互斥锁,别的线程才能得到这个互斥锁。如果试图解锁一个由别的线程锁定的互斥锁将会返回一个错误代码。如果一个线程试图解锁已经被解锁的互斥锁也将会返回一个错误代码。这种类型的互斥锁只能是进程私有的(作用域属性PTHREAD_PROCESS_PRIVATE)PTHREAD_MUTEX_DEFAULT
:就是NORMAL类型
锁作用域:
PTHREAD_PROCESS_PRIVATE
:缺省值,作用域为进程内PTHREAD_PROCESS_SHARED
:作用域为进程间
使用示例:
1 | static pthread_mutex_t c_lock; |
使用锁的注意点
互斥量需要时间来加锁和解锁。锁住较少互斥量的程序通常运行得更快。所以,互斥量应该尽量少,够用即可,每个互斥量保护的区域应则尽量大。
互斥量的本质是串行执行。如果很多线程需要领繁地加锁同一个互斥量,
则线程的大部分时间就会在等待,这对性能是有害的。如果互斥量保护的数据(或代码)包含彼此无关的片段,则可以特大的互斥量分解为几个小的互斥量来提高性能。这样,任意时刻需要小互斥量的线程减少,线程等待时间就会减少。所以,互斥量应该足够多(到有意义的地步),每个互斥量保护的区域则应尽量的少。
参考文档
Posix互斥量pthread_mutex_t
iOS 常见知识点(三):Lock
不再安全的 OSSpinLock
How does @synchronized lock/unlock in Objective-C?
[爆栈热门 iOS 问题] atomic 和 nonatomic 有什么区别?
《高性能iOS应用开发中文版》