接着上篇文章中,对死锁情况的一些分享,本文再来说说多线程使用中的安全隐患以及解决方案。
多线程安全隐患
使用多线程开发时会有哪些安全隐患呢?也许你开发中曾遇到过,比如多个线程同时访问同一个变量,同一处内存,对他们同时进行读写操作,就会出现不安全的情况,通俗的说就是你访问到的变量的值或者内存中的数据,可能并不是你想要的,他再被多个线程访问的时候,数据出现了错乱。
比如常见的多个窗口卖票问题,是比较经典的用来模拟多线程同时访问的例子
- (void)saleTicket {
int oldTicketsCount = self.ticketsCount;
sleep(.2);//为了更逼真的模拟多线程同时访问
oldTicketsCount--;
self.ticketsCount = oldTicketsCount;
NSLog(@"还剩%d张票 - %@",self.ticketsCount, [NSThread currentThread]);
}
//演示卖票的操作
- (void)saleTickets {
self.ticketsCount = 9;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (int i = 0; i < 3; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 3; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 3; i++) {
[self saleTicket];
}
});
}
多个窗口同时去访问票数ticketsCount这个变量,并对这个变量进行减1的操作,如果不做线程保护,就会存在安全隐患
再来一个存钱取钱的例子,模拟多线程访问
//存钱
- (void)saveMoney {
int oldMoney = self.money;
sleep(.2);
oldMoney += 50;
self.money = oldMoney;
NSLog(@"存50 还剩%d元 - %@",self.money, [NSThread currentThread]);
}
//取钱
- (void)drawMoney {
int oldMoney = self.money;
sleep(.2);
oldMoney -= 20;
self.money = oldMoney;
NSLog(@"取20 还剩%d元 - %@",self.money, [NSThread currentThread]);
}
- (void)moneyTest {
self.money = 100;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (int i = 0; i < 10; i++) {
[self saveMoney];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 10; i++) {
[self drawMoney];
}
});
}
执行之后会发现,最后剩余的钱数根本不对,而且可能每次执行最后的结果都不一样,同样也是存在安全隐患
经过上面两个例子,大家应该对什么是多线程安全隐患有了自己的理解,以及为什么会出现隐患,就是一个变量,他是不应该被同时进行读写操作的,要么同一时间只能读,要么只能写(改变他的值)。
所以如何处理这样的安全隐患呢,就需要使用线程同步技术了(同步,就是按预定的先后次序进行),这个概念在上篇文章中也已经说过了,只是通过dispatch_sync同步执行任务并不会开启新的线程。而本文我们分析的场景是需要有多个线程的。
那常见的线程同步技术就是下面我们要分享的知识点----加锁。
线程同步方案(锁)
iOS中的线程同步方案比较多,下面先列一下使用加锁进行线程同步的方案
- OSSpinLock
- os_unfair_lock
- pthread_mutex
- NSLock
- NSRecursiveLock
- NSCondition
- NSConditionLock
- @synchronized
除了上面这些,还有GCD中的信号量和同步队列也可以实现线程同步 - dispatch_semaphore
- dispatch_queue()
先说加锁这种方案,其实加锁的原理也比较简单,就是判断这把锁有没有被别人加过 ,如果加过就等待(相当于线程阻塞),等待解锁才去访问 ,但是如果每次都是新的锁 ,则都可以去加锁 ,就达不到加锁的效果了。
下面逐个分析上面每一个同步方案,都以卖票和存取钱的demo为例,解决这两个例子的安全隐患。
OSSpinLock
OSSpinLock:自旋锁,所谓自旋呢,就是忙等,等待的线程会一直处于忙等状态,一直占用着CPU资源。目前OSSpinLock已经不再安全,从iOS10开始就不推荐使用了。
下面简单说一下他的使用,需要导入libkern/OSAtomic.h头文件
#import <libkern/OSAtomic.h>
@interface OSSpinLockDemo()
@property (nonatomic, assign) OSSpinLock moneylock;
//@property (nonatomic, assign) OSSpinLock ticketlock;
@end
@implementation OSSpinLockDemo
- (instancetype)init
{
self = [super init];
if (self) {
self.moneylock = OS_SPINLOCK_INIT;
// self.ticketlock = OS_SPINLOCK_INIT;
}
return self;
}
- (void)__saveMoney {
OSSpinLockLock(&_moneylock);
[super __saveMoney];
OSSpinLockUnlock(&_moneylock);
}
- (void)__drawMoney {
OSSpinLockLock(&_moneylock);
[super __drawMoney];
OSSpinLockUnlock(&_moneylock);
}
- (void)__saleTicket {
// static 变量也可以 因为#define OS_SPINLOCK_INIT 0
static OSSpinLock ticketlock = OS_SPINLOCK_INIT;
OSSpinLockLock(&ticketlock);
[super __saleTicket];
OSSpinLockUnlock(&ticketlock);
}
@end
这里有两个点强调一下,也是后面分析其他方案都需要注意的:
- 存钱取钱的例子,和卖票的例子不同点在于多线程中是调用了两个不同的方法,而存取也是不能同时进行的,所以存取需要用同一把锁
- 方法内部如果lock是个局部变量, 每次进这个方法都会重新初始化一个锁,会导致加锁失败,也必须用同一把锁加锁,所以可以用成员变量或者static变量,或者用dispatch_once创建锁 都可以
不安全
为什么说OSSpinLock现在已经不再安全了,其实还是这个锁的性质决定的。自旋锁,等待的线程都处于忙等,可能会出现优先级反转的问题。
比如优先级低的线程先加锁, 优先级高的线程再访问进入忙等, CPU一直分配资源给优先级高的线程, 可能导致没有资源分给优先级低的线程 ,导致优先级低的线程一直不能解锁 ,高的一直等待还占着CPU资源。
所以就有了os_unfair_lock,用于取代OSSpinLock。
os_unfair_lock
os_unfair_lock从iOS10之后开始支持,用于取代不安全的OSSpinLock,之所以能取代OSSpinLock,他是如何解决安全性的呢?
这里要知道线程阻塞的两种方案
- 线程睡眠 不占用CPU
- 忙等 一直占用这CPU
而os_unfair_lock底层实现就是使线程进入睡眠状态,并非忙等。
使用上和OSSpinLock类似,需要导入os/lock.h
#import <os/lock.h>
@interface OSUnfairLockDemo()
@property (nonatomic, assign) os_unfair_lock moneylock;
@property (nonatomic, assign) os_unfair_lock ticketlock;
@end
@implementation OSUnfairLockDemo
- (instancetype)init
{
self = [super init];
if (self) {
self.moneylock = OS_UNFAIR_LOCK_INIT;
self.ticketlock = OS_UNFAIR_LOCK_INIT;
}
return self;
}
// 只加锁不解锁 相当于死锁 或者加锁和解锁的不是同一把锁
- (void)__saveMoney {
os_unfair_lock_lock(&_moneylock);
[super __saveMoney];
os_unfair_lock_unlock(&_moneylock);
}
- (void)__drawMoney {
os_unfair_lock_lock(&_moneylock);
[super __drawMoney];
os_unfair_lock_unlock(&_moneylock);
}
- (void)__saleTicket {
os_unfair_lock_lock(&_ticketlock);
[super __saleTicket];
os_unfair_lock_unlock(&_ticketlock);
}
@end
这里注意:只加锁不解锁 相当于死锁 或者加锁和解锁的不是同一把锁
pthread_mutex
看到pthread开头的东西,是不是不那么陌生了,上一篇文章中的多线程方案里面就有pthread,是个跨平台的多线程方案,这里的锁也是,后续要介绍的几个锁也都是基于他封装的。
互斥锁
pthread_mutex:互斥锁,等待锁的线程会处于休眠状态
与上面两种锁相比还有一点,是需要自己销毁,使用上也比较简单,需要导入pthread.h
#import <pthread.h>
@interface MutexDemo()
@property (nonatomic, assign) pthread_mutex_t moneylock;
@property (nonatomic, assign) pthread_mutex_t ticketlock;
@end
@implementation MutexDemo
- (void)_initMutex:(pthread_mutex_t)mutex {
// 初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);
// 初始化锁
pthread_mutex_init(&mutex, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);
}
- (instancetype)init {
self = [super init];
if (self) {
// pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 静态初始化
[self _initMutex:_moneylock];
[self _initMutex:_ticketlock];
}
return self;
}
- (void)__saveMoney {
pthread_mutex_lock(&_moneylock);
[super __saveMoney];
pthread_mutex_unlock(&_moneylock);
}
- (void)__drawMoney {
pthread_mutex_lock(&_moneylock);
[super __drawMoney];
pthread_mutex_unlock(&_moneylock);
}
- (void)__saleTicket {
pthread_mutex_lock(&_ticketlock);
[super __saleTicket];
pthread_mutex_unlock(&_ticketlock);
}
- (void)dealloc
{
pthread_mutex_destroy(&_moneylock);
pthread_mutex_destroy(&_ticketlock);
}
@end
递归锁
pthread_mutex不仅可以创建互斥锁,也可以创建递归锁,pthread_mutexattr_settype()这个方法的第二个参数,是一个枚举值,设置为默认值PTHREAD_MUTEX_NORMAL,就是互斥锁,设置为PTHREAD_MUTEX_RECURSIVE就是递归锁。
递归锁顾名思义是在递归调用中加的锁,因为递归锁允许 同一个线程 对一把锁进行重复加锁,如果不是递归锁,在发生递归调用的时候,会因为已经加锁,而无法继续递归调用。
- (void)recursiveLock {
__block pthread_mutex_t recursiveMutex;
pthread_mutexattr_t recursiveMutexattr;
pthread_mutexattr_init(&recursiveMutexattr);
pthread_mutexattr_settype(&recursiveMutexattr, PTHREAD_MUTEX_NORMAL);
pthread_mutex_init(&recursiveMutex, &recursiveMutexattr);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
static void (^RecursiveBlock)(int);
RecursiveBlock = ^(int value) {
pthread_mutex_lock(&recursiveMutex);
// 这里要设置递归结束的条件或次数,不然会无限递归下去
if (value > 0) {
NSLog(@"处理中... %d",value);
sleep(1);
RecursiveBlock(--value);
}
NSLog(@"处理完成!");
pthread_mutex_unlock(&recursiveMutex);
};
RecursiveBlock(4);
});
}
这段代码,因为使用的是互斥锁,导致线程阻塞,只处理了一次就“死锁”了
而修改为递归锁,pthread_mutexattr_settype(&recursiveMutexattr, PTHREAD_MUTEX_RECURSIVE);
就可以加锁和解锁成对出现,正常处理完所有递归操作
条件
除了上面说的两种情况,pthread_mutex加锁还可以另外添加一个条件,用条件限制是否需要进入休眠,这种加锁情况应用的场景跟存取钱和卖票的场景不太一样,不是说要么加锁要么不加,而是在特定的条件下才去加锁,如果满足条件了就解锁。
初始化锁和条件:
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);
// 初始化锁
pthread_mutex_init(&_mutex, &attr);
// 销毁属性
pthread_mutexattr_destroy(&attr);
// 初始化条件
pthread_cond_init(&_cond, NULL);
应用加锁和条件判断:
- (void)__add {
pthread_mutex_lock(&_mutex);
[self.data addObject:@"str"];
NSLog(@"%s",__func__);
// 信号 唤醒 然后解锁
pthread_cond_signal(&_cond);
// 广播 所有等待这个条件的线程
// pthread_cond_broadcast(&_cond);
pthread_mutex_unlock(&_mutex);
}
- (void)__remove {
NSLog(@"__remove begin");
pthread_mutex_lock(&_mutex);
if (self.data.count == 0) {
// 解锁 等待被条件唤醒 被唤醒 再加锁
pthread_cond_wait(&_cond, &_mutex);
}
[self.data removeLastObject];
NSLog(@"%s",__func__);
pthread_mutex_unlock(&_mutex);
}
remove方法中,如果if条件满足,数组中并没有元素,就通过pthread_cond_wait进入休眠,这里休眠之后会阻塞在这里并解锁,所以执行add就可以正常加锁,等到add方法中pthread_cond_signal或者pthread_cond_broadcast来唤醒wait,再继续执行remove中后面的代码。
这里有个点需要注意一下,signal(或者broadcast)之后, remove方法中的wait并不能立刻被唤醒然后加锁 ,还是需要先解锁 ,wait处才能加锁 signal放在unlock后面也可以 ,那样信号发出之后remove方法中锁会被立刻唤醒并往下执行
NSLock 、 NSRecursiveLock
NSLock就是对mutex默认锁的封装,而NSRecursiveLock就是对mutex递归锁的封装,API都比较简单,使用起来更加面向对象。
@protocol NSLocking
- (void)lock;
- (void)unlock;
@end
@interface NSLock : NSObject <NSLocking> {
@private
void *_priv;
}
- (BOOL)tryLock;
- (BOOL)lockBeforeDate:(NSDate *)limit;
NSCondition
NSCondition是对mutex和cond的封装,就是把锁和条件都封装在了一起,所以使用中就不需要再定义锁了。使用也和mutex类似:
- (void)__add {
[self.condition lock];
[self.data addObject:@"str"];
NSLog(@"%s",__func__);
[self.condition signal];
// 广播 所有等待这个条件的线程
// [self.condition broadcast];
sleep(2);
[self.condition unlock];
}
- (void)__remove {
NSLog(@"__remove begin");
[self.condition lock];
if (self.data.count == 0) {
[self.condition wait];
}
[self.data removeLastObject];
NSLog(@"%s",__func__);
[self.condition unlock];
}
NSConditionLock
NSConditionLock是对NSCondition的进一步封装,可以设置具体的条件值
@property (readonly) NSInteger condition;
- (void)lockWhenCondition:(NSInteger)condition;
- (BOOL)tryLock;
- (BOOL)tryLockWhenCondition:(NSInteger)condition;
- (void)unlockWithCondition:(NSInteger)condition;
- (BOOL)lockBeforeDate:(NSDate *)limit;
- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;
使用示例:
- (instancetype)init
{
self = [super init];
if (self) {
self.conditionLock = [[NSConditionLock alloc] initWithCondition:1];
self.data = [NSMutableArray array];
}
return self;
}
- (void)__add {
[self.conditionLock lockWhenCondition:2];
[self.data addObject:@"str"];
NSLog(@"%s",__func__);
[self.conditionLock unlock];
}
- (void)__remove {
NSLog(@"__remove begin");
[self.conditionLock lockWhenCondition:1];
[self.data removeLastObject];
NSLog(@"%s",__func__);
[self.conditionLock unlockWithCondition:2];
}
Condition初始化如果不设置条件值默认是0,直接调用lock加锁,就不会判断条件值了,只有调用lockWhenCondition时才去判断条件值。
NSConditionLock不仅可以保证线程同步,还可以实现线程依赖 ,用条件限制执行的先后顺序。
dispatch_queue
直接用GCD的串行队列,也可以实现线程同步,串行队列,就是保证多线程的操作按顺序执行。
self.moneyQueue = dispatch_queue_create("ticketQueue", DISPATCH_QUEUE_SERIAL);
dispatch_sync(self.moneyQueue, ^{
[super __saveMoney];
});
dispatch_semaphore
dispatch_semaphore:信号量 ,信号量的初始值可以用来控制线程并发访问的最大数量,当设置为1的时候,则代表同时只允许一条线程访问资源,保证了线程同步。
核心的两个方法:dispatch_semaphore_wait如果信号量的值 >0, 就会让信号量值-1 , 然后继续执行下面的代码,如果信号量的值 <= 0 ,就会休眠等待, 直到信号量 > 0
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
dispatch_semaphore_signal:让信号量+1
dispatch_semaphore_signal(self.semaphore);
@synchronized
@synchronized是用法是最简单的锁, 但是并不推荐使用 ,效率低。底层也是对pthread_mutex 递归锁的封装。
使用上需要注意,保证@synchronized (self),括号中的对象 ,都是同一个对象 ,才能起到加锁的作用。
- (void)__saveMoney {
@synchronized (self) {
[super __saveMoney];
}
}
性能比较
上面说的这一系列线程同步方案的性能按从高到低排序:
os_unfair_lock > OSSpinLock > dispatch_semaphore > pthread_mutex > dispatch_queue > NSLock > NSCondition > NSRecursiveLock > NSConditionLock > synchronized
总结一下:
- os_unfair_lock虽然性能高,但是从iOS10才开始支持,有版本限制;
- 推荐使用信号量和mutex;
- 持续封装的,多多少少都会影响一些性能;
- 能不用就不用递归锁;
自旋锁和互斥锁
了解了自旋锁和互斥锁的工作原理,我们再分析一下不同场景下如何抉择用哪一种锁
适用于自旋锁的情况:
- 线程等待时间很短,就直接忙等了,毕竟线程休眠再唤醒也是一个耗性能的过程
- 被加锁的代码被频繁调用,但是竞争情况很少
- CPU不紧张 ,多核处理器
适用于互斥锁的情况:
- 线程等待时间较长
- 被加锁的代码复杂、循环量大、或有IO操作、或竞争激烈
- 单核处理器
这里只是客观的分析一下,其实自旋锁已经不推荐使用了,但是为什么不推荐还是要知道的。在开发过程中,需要使用哪种线程同步的方案还是根据上面总结的,推荐使用信号量和mutex,mutex虽然用起来可能不顺手但好在性能够好,该用也得用一用。
这篇文章也写的够长了,也用了好几天的时间去看去写代码测试,就先写这些吧,后面还有一点儿关于读写锁的知识就放在下一篇文章中了,本文用到的所有示例代码都已经上传github,感兴趣的可以看看,细节部分都加了注释。
2020-08-14补充:
补充一个信号量的简单用法,估计很多看过SDWebImage
源码的小伙伴都知道我要说什么了,在SDWebImageDownloader
等好几个类中,就使用了信号量进行加锁,然后通过宏定义的方式,简化加锁操作。
#define LOCK(lock) dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
#define UNLOCK(lock) dispatch_semaphore_signal(lock);
或者也可以这样:把信号量的初始化也放在宏定义中
#define SemaphoreBegin \
static dispatch_semaphore_t semaphore; \
static dispatch_once_t onceToken; \
dispatch_once(&onceToken, ^{ \
semaphore = dispatch_semaphore_create(1); \
});\
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
#define SemaphoreEnd \
dispatch_semaphore_signal(semaphore);