一. 安全隐患
利用多线程异步可以同时做不同的事情,效率更高,但是这样也会有安全隐患。
造成安全隐患的原因:
一块资源可能会被多个线程共享,也就是多个线程可能会访问同一块资源,比如多个线程访问同一个对象、同一个变量、同一个文件。当多个线程访问同一块资源时,很容易引发数据错乱和数据安全问题。
两个比较经典的问题:
下面用代码来验证卖票问题:
- (void)viewDidLoad {
[super viewDidLoad];
[self ticketTest];
}
/**
卖票演示
*/
- (void)ticketTest
{
self.ticketsCount = 15;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
}
/**
卖1张票
*/
- (void)saleTicket
{
int oldTicketsCount = self.ticketsCount;
sleep(.2);//为了凸显多线程的安全隐患
oldTicketsCount--;
self.ticketsCount = oldTicketsCount;
NSLog(@"还剩%d张票 - %@", oldTicketsCount, [NSThread currentThread]);
}
打印:
还剩14张票 - {number = 5, name = (null)}
还剩14张票 - {number = 3, name = (null)}
还剩14张票 - {number = 4, name = (null)}
还剩12张票 - {number = 4, name = (null)}
还剩11张票 - {number = 3, name = (null)}
还剩13张票 - {number = 5, name = (null)}
还剩10张票 - {number = 4, name = (null)}
还剩9张票 - {number = 3, name = (null)}
还剩8张票 - {number = 5, name = (null)}
还剩7张票 - {number = 4, name = (null)}
还剩6张票 - {number = 3, name = (null)}
还剩5张票 - {number = 4, name = (null)}
还剩4张票 - {number = 5, name = (null)}
还剩3张票 - {number = 3, name = (null)}
还剩2张票 - {number = 5, name = (null)}
可以发现卖票15次,一次卖1张,还剩2张,说明有问题。
存钱取钱问题:
- (void)viewDidLoad {
[super viewDidLoad];
[self moneyTest];
}
/**
存钱、取钱演示
*/
- (void)moneyTest
{
self.money = 100;
//存500取200应该还剩400
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];
}
});
}
/**
存钱50块 存500
*/
- (void)saveMoney
{
int oldMoney = self.money;
sleep(.2);
oldMoney += 50;
self.money = oldMoney;
NSLog(@"存50,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
}
/**
取钱20块 取200
*/
- (void)drawMoney
{
int oldMoney = self.money;
sleep(.2);
oldMoney -= 20;
self.money = oldMoney;
NSLog(@"取20,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
}
打印:
......
存50,还剩380元 - {number = 3, name = (null)}
取20,还剩360元 - {number = 4, name = (null)}
取20,还剩340元 - {number = 4, name = (null)}
存50,还剩410元 - {number = 3, name = (null)}
存50,还剩460元 - {number = 3, name = (null)}
存500取200,加上原来的100,应该还剩400,打印460有问题。
分析造成安全隐患的原因:
如图,按理说两个线程对17分别进行两次+1操作,应该是19,最后是18,就是因为这个安全隐患造成的。
那么如何解决安全隐患呢?
二. 解决方案
使用线程同步技术(同步,就是协同步调,按预定的先后次序进行)解决线程的安全隐患,常见的线程同步技术是:加锁。
加锁实现原理如下图,就不解释了:
三. 加锁方案
OSSpinLock 自旋锁
os_unfair_lock
pthread_mutex
dispatch_semaphore 信号量
dispatch_queue(DISPATCH_QUEUE_SERIAL) 串行队列
NSLock
NSRecursiveLock
NSCondition
NSConditionLock
@synchronized
1. OSSpinLock
OSSpinLock叫做”自旋锁”,等待锁的线程会处于忙等(busy-wait)状态,一直占用着CPU资源。
- 目前已经不再安全,可能会出现优先级反转问题,也就是,如果等待锁的线程优先级较高,它会一直占用着CPU资源,优先级低的线程就无法释放锁。
- 需要导入头文件#import
使用自旋锁解决上面问题:
#import "ViewController.h"
#import
@interface ViewController ()
@property (assign, nonatomic) int money;
@property (assign, nonatomic) int ticketsCount;
@property (assign, nonatomic) OSSpinLock lock;
@property (assign, nonatomic) OSSpinLock lock1;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 初始化锁,要所有的线程都用同一把锁
self.lock = OS_SPINLOCK_INIT;
self.lock1 = OS_SPINLOCK_INIT;
[self ticketTest];
[self moneyTest];
}
/**
存钱、取钱演示
*/
- (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];
}
});
}
/**
存钱
*/
- (void)saveMoney
{
// 加锁
OSSpinLockLock(&_lock1);
int oldMoney = self.money;
sleep(.2);
oldMoney += 50;
self.money = oldMoney;
NSLog(@"存50,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
// 解锁
OSSpinLockUnlock(&_lock1);
}
/**
取钱
*/
- (void)drawMoney
{
// 加锁
OSSpinLockLock(&_lock1);
int oldMoney = self.money;
sleep(.2);
oldMoney -= 20;
self.money = oldMoney;
NSLog(@"取20,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
// 解锁
OSSpinLockUnlock(&_lock1);
}
/**
卖1张票
*/
- (void)saleTicket
{
// 加锁
OSSpinLockLock(&_lock);
int oldTicketsCount = self.ticketsCount;
sleep(.2);
oldTicketsCount--;
self.ticketsCount = oldTicketsCount;
NSLog(@"还剩%d张票 - %@", oldTicketsCount, [NSThread currentThread]);
// 解锁
OSSpinLockUnlock(&_lock);
}
/**
卖票演示
*/
- (void)ticketTest
{
self.ticketsCount = 15;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
}
@end
打印:
......
还剩0张票 - {number = 3, name = (null)}
......
取20,还剩400元 - {number = 3, name = (null)}
说明加锁成功。
- OSSpinLockLock(&_lock)这行代码里面做的事情:加锁之前会判断这把锁有没有在加锁,如果在加锁就阻塞在OSSpinLockLock(&_lock)这一行,等这把锁解锁完,再加锁。
- 卖票只需要saleTicket方法里面加锁,存钱取钱需要在saveMoney和drawMoney里面都要加锁,而且要使用同一把锁。
- 自旋锁就是下面方式②,类似于写了个while循环,一直占用cpu资源。
线程阻塞方式有:
① 让线程睡眠,不占用cpu资源
② while循环,一直占用cpu资源
优先级反转问题:
上面说了自旋锁有可能造成优先级反转问题,下面解释这个:
首先,CPU给多个线程分配任务的时候采用的是时间片轮转调度算法(进程、线程),意思就是当thread1、thread2、thread3同时执行任务,如果thread1的优先级> thread2,那么给thread1分配时间的概率就大一些,CPU就是不断的给线程分配时间,线程拿到CPU分配的时间做事情,这样就感觉三个线程是同时做事情,这就叫时间片轮转调度算法。
在上面的自旋锁代码中:
- (void)drawMoney
{
// 加锁
OSSpinLockLock(&_lock1);
int oldMoney = self.money;
sleep(.2);
oldMoney -= 20;
self.money = oldMoney;
NSLog(@"取20,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
// 解锁
OSSpinLockUnlock(&_lock1);
}
如果线程2先来到上面代码,线程2发现没有加锁,就会加锁,加锁之后线程1又来了,线程1发现已经加锁了,就会处于忙等状态(相当于一直执行while),由于线程1优先级大于线程2,这时候CPU就会给线程1分配更多的时间,这时候大量的时间都分配给线程1执行while了,线程2就没有时间执行完它的代码,也就不会解锁了,这时候线程1就会一直在等,就产生类似死锁的感觉了,这样这把锁就有可能一直放不开。
其实自旋锁效率是比较高的,因为它是类似while循环,没有休眠(线程从休眠到唤醒也是需要时间的),但是就因为自旋锁阻塞的方式是类似while循环,所以说这把锁已经不安全了,不推荐使用。
补充:自旋锁加锁的另外一种方式
// 尝试加锁,如果能加锁就加锁,不能加锁,返回加锁失败就继续往下走,这种方式不会阻塞线程。
if (OSSpinLockTry(&_lock)) {
int oldTicketsCount = self.ticketsCount;
sleep(.2);
oldTicketsCount--;
self.ticketsCount = oldTicketsCount;
NSLog(@"还剩%d张票 - %@", oldTicketsCount, [NSThread currentThread]);
OSSpinLockUnlock(&_lock);
}
2. 其他加锁方案
上面只介绍了自旋锁,后面还有好几种锁,为了演示方便,在文末的Demo中对重复代码进行了抽取。
其中OSSpinLockDemo是中规中矩的代码抽取,OSSpinLockDemo2中没有使用属性,使用了static变量:
#import "OSSpinLockDemo2.h"
#import
@implementation OSSpinLockDemo2
//只有当前文件可以访问
static OSSpinLock moneyLock_;
+ (void)initialize
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
//OS_SPINLOCK_INIT 就是 0
moneyLock_ = 0;
});
}
- (void)__drawMoney
{
OSSpinLockLock(&moneyLock_);
[super __drawMoney];
OSSpinLockUnlock(&moneyLock_);
}
- (void)__saveMoney
{
OSSpinLockLock(&moneyLock_);
[super __saveMoney];
OSSpinLockUnlock(&moneyLock_);
}
- (void)__saleTicket
{
//延长局部变量生命周期,至程序结束
static OSSpinLock ticketLock = OS_SPINLOCK_INIT;
OSSpinLockLock(&ticketLock);
[super __saleTicket];
OSSpinLockUnlock(&ticketLock);
}
@end
关于static:
关于上面的代码有两个知识点:
- static的作用
static修饰局部变量会延长局部变量生命周期,至程序结束
static修饰全局变量,那么全局变量作用域只在本文件
自己思考一下,为什么要用static修饰上面的全局变量和局部变量?
- static是静态初始化,右边只能放一个值
比如上面的 static OSSpinLock ticketLock = OS_SPINLOCK_INIT;
点进去:
#define OS_SPINLOCK_INIT 0
发现OS_SPINLOCK_INIT就是0,所以可以这么写。
再举个例子:
static NSString *str = [NSString stringWithFormat:@"123"];
上面代码会报错,因为被static修饰的变量在编译的时候就要知道值,而右边的函数只有在运行的时候才知道值。
修改为如下代码就不报错了:
static NSString *str = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
str = [NSString stringWithFormat:@"123"];
});
加锁的条件:
只有多条线程同时修改同一个变量的时候才需要加锁。
比如如下代码:
- (void)viewDidLoad {
[super viewDidLoad];
MJBaseDemo *demo = [[OSSpinLockDemo2 alloc] init];
for (int i = 0; i < 10; i++) {
[[[NSThread alloc] initWithTarget:self selector:@selector(test) object:nil] start];
}
}
- (int)test
{
int a = 10;
int b = 20;
//NSLog(@"%p", self.demo);
int c = a + b;
return c;
}
上面代码虽然同时调用了test方法,但是不用加锁,因为他们访问的都是局部变量,不是同一个变量,没必要加锁。
就算把上面NSLog(@"%p", self.demo);注释打开,这里访问了同一个变量了,但是只是打印一下,没有修改它的值,也不用加锁。
其他加锁方案请参考:其他加锁方案
Demo地址:线程安全、自旋锁