好看的美女让人脑子清醒,开始吧!
1. 什么情况下会有线程隐患?
我们在使用多线程技术带来的便利的同时,也需要考虑下多线程所带来的隐患。比如,我们可以并发的进行多个任务,因此同一块资源就可能在多个线程中同时被访问(读/写),这个现象叫做资源共享,比如多个线程同时访问了同一个对象\变量\文件,这样就有可能引发数据错乱和数据安全问题。
2. 两个常见的示例
2.1 示例:存钱取钱
存钱取钱问题我们用代码来实现一下这个功能:
- (void)moneyTest{
self.money = 1000;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 10; i++) {
[self saveMoney];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 10; i++) {
[self drawMoney];
}
});
}
// 存钱
- (void)saveMoney{
NSInteger oldMoney = self.money;
sleep(0.5);
oldMoney += 50;
self.money = oldMoney;
NSLog(@"存了50元,账户余额 %ld,当前线程 %@",self.money,[NSThread currentThread]);
}
// 取钱
- (void)drawMoney{
NSInteger oldMoney = self.money;
sleep(0.5);
oldMoney -= 20;
self.money = oldMoney;
NSLog(@"取了20元,账户余额 %ld,当前线程 %@",self.money,[NSThread currentThread]);
}
输出结果:
2019-10-12 16:40:18.033330+0800 tianran[16615:4423457] 存了50元,账户余额 1050,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.033486+0800 tianran[16615:4423457] 存了50元,账户余额 1080,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.033500+0800 tianran[16615:4423456] 取了20元,账户余额 1030,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.033573+0800 tianran[16615:4423457] 存了50元,账户余额 1130,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.033632+0800 tianran[16615:4423456] 取了20元,账户余额 1110,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.033659+0800 tianran[16615:4423457] 存了50元,账户余额 1160,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.033730+0800 tianran[16615:4423456] 取了20元,账户余额 1140,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.033737+0800 tianran[16615:4423457] 存了50元,账户余额 1190,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.033821+0800 tianran[16615:4423457] 存了50元,账户余额 1240,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.033839+0800 tianran[16615:4423456] 取了20元,账户余额 1170,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.033949+0800 tianran[16615:4423457] 存了50元,账户余额 1220,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.034040+0800 tianran[16615:4423456] 取了20元,账户余额 1200,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.034178+0800 tianran[16615:4423457] 存了50元,账户余额 1250,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.034341+0800 tianran[16615:4423456] 取了20元,账户余额 1230,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.034423+0800 tianran[16615:4423457] 存了50元,账户余额 1280,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.034581+0800 tianran[16615:4423456] 取了20元,账户余额 1260,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.034665+0800 tianran[16615:4423457] 存了50元,账户余额 1310,当前线程 <NSThread: 0x2829d1840>{number = 3, name = (null)}
2019-10-12 16:40:18.034803+0800 tianran[16615:4423456] 取了20元,账户余额 1290,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.035029+0800 tianran[16615:4423456] 取了20元,账户余额 1270,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
2019-10-12 16:40:18.035105+0800 tianran[16615:4423456] 取了20元,账户余额 1250,当前线程 <NSThread: 0x2829d91c0>{number = 4, name = (null)}
我们在moneyTest方法中,以多线程方式分别进行了10次的存/取钱操作,每次存50,每次取20,最后执行完之后余额应该为1000 + (50 * 10) - (20 * 10) = 1300,但是上面输出的结果是1250,很明显,出大问题了。
2.2 示例:卖票问题**
卖票问题我们通过代码展示一下这个问题:
- (void)ticketTest{
self.ticketNum = 30;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
}
// 卖票操作
- (void)saleTicket{
self.ticketNum -= 1;
NSLog(@"还剩%ld张票,currentThread - %@",self.ticketNum,[NSThread currentThread]);
}
输出结果:
2019-10-12 16:46:38.501449+0800 tianran[16620:4425096] 还剩29张票,currentThread - <NSThread: 0x28335a9c0>{number = 5, name = (null)}
2019-10-12 16:46:38.501573+0800 tianran[16620:4425094] 还剩28张票,currentThread - <NSThread: 0x2833530c0>{number = 3, name = (null)}
2019-10-12 16:46:38.501593+0800 tianran[16620:4425096] 还剩27张票,currentThread - <NSThread: 0x28335a9c0>{number = 5, name = (null)}
2019-10-12 16:46:38.501684+0800 tianran[16620:4425096] 还剩25张票,currentThread - <NSThread: 0x28335a9c0>{number = 5, name = (null)}
2019-10-12 16:46:38.501693+0800 tianran[16620:4425094] 还剩24张票,currentThread - <NSThread: 0x2833530c0>{number = 3, name = (null)}
2019-10-12 16:46:38.501689+0800 tianran[16620:4425093] 还剩26张票,currentThread - <NSThread: 0x28336aa40>{number = 6, name = (null)}
2019-10-12 16:46:38.501753+0800 tianran[16620:4425096] 还剩23张票,currentThread - <NSThread: 0x28335a9c0>{number = 5, name = (null)}
2019-10-12 16:46:38.501776+0800 tianran[16620:4425094] 还剩22张票,currentThread - <NSThread: 0x2833530c0>{number = 3, name = (null)}
2019-10-12 16:46:38.501781+0800 tianran[16620:4425093] 还剩21张票,currentThread - <NSThread: 0x28336aa40>{number = 6, name = (null)}
2019-10-12 16:46:38.501877+0800 tianran[16620:4425094] 还剩19张票,currentThread - <NSThread: 0x2833530c0>{number = 3, name = (null)}
2019-10-12 16:46:38.501819+0800 tianran[16620:4425096] 还剩20张票,currentThread - <NSThread: 0x28335a9c0>{number = 5, name = (null)}
2019-10-12 16:46:38.502044+0800 tianran[16620:4425093] 还剩18张票,currentThread - <NSThread: 0x28336aa40>{number = 6, name = (null)}
2019-10-12 16:46:38.502404+0800 tianran[16620:4425094] 还剩17张票,currentThread - <NSThread: 0x2833530c0>{number = 3, name = (null)}
2019-10-12 16:46:38.502529+0800 tianran[16620:4425093] 还剩16张票,currentThread - <NSThread: 0x28336aa40>{number = 6, name = (null)}
2019-10-12 16:46:38.502760+0800 tianran[16620:4425093] 还剩15张票,currentThread - <NSThread: 0x28336aa40>{number = 6, name = (null)}
虽然最后的结果是一样的,但是我们可以看到中间卖票的时候,票数有问题了。
上面两个问题都是由于多个线程对同一资源进行了读写操作而导致的,下面用一个熟悉的图片来表示下:
image.png
针对这个问题的解决方案:使用线程同步技术(同步,就是协同步调,按预定的先后次序进行)。常见的线程同步技术就是:加锁。
如下图所示:
image.png
在进行操作的时候,先加锁,保证此时只有一个线程对资源进行操作,操作完成后在解锁。
3. 线程同步(加锁)方案
- OSSpinLock
- os_unfair_lock
- pthread_mutex
- dispatch_semaphore
- dispatch_queue(DISPATCH_QUEUE_SERIAL)
- NSLock
- NSRecursiveLock
- NSCondition
- NSConditionLock
- @synchronized
介绍锁之前,我们可以先看几个概念定义:
- 临界区:
指的是一块对公共资源进行访问的代码,并非一种机制或算法 - 自旋锁:
用于多线程同步的一种锁,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种 忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。自旋锁避免了线程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是非常有效的。 - 互斥锁(Mutex):
是一种用于多线程编程中,防止多条线程同时对同一公共资源(比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区而达成。 - 读写锁:
是计算机程序的并发控制的一种同步机制,也称“共享-互斥锁”或者“多读-单写锁”,用于解决多线程对公共资源读写问题。读操作可并发重入,写操作是互斥的。读写锁通常用互斥锁、条件变量、信号量实现。 - 信号量(semaphore):
是一种更高级的同步机制,互斥锁可以说是semaphore在仅取值0/1时的特例。信号量可以有更多的取值空间,用来实现更加负责的同步,而不单单是线程间互斥。 - 条件锁:
就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就是锁住了,当资源被分配到了,条件锁打开,进程继续运行。
现在,我们依次学习一下。
3.1 OSSpinLock
OSSpinLock叫做“自旋锁”,需要导入头文件
#import <libkern/OSAtomic.h>
常用的API:
OSSpinLock lock = OS_UNFAIR_LOCK_INIT; ——初始化锁对象lock
OSSpinLockTry(&lock);——尝试加锁,加锁成功继续,加锁失败返回,继续执行后面的代码,不阻塞线程
OSSpinLockLock(&lock);——加锁,加锁失败会阻塞线程,进行等待
OSSpinLockUnlock(&lock);——解锁
我们使用上面的第二个卖票示例来进行加锁操作:
#import <libkern/OSAtomic.h>
@interface ViewController ()
// 票总数
@property (nonatomic, assign) NSInteger ticketNum;
// 锁
@property (nonatomic, assign) OSSpinLock lock;
@end
- (void)ticketTest{
// 初始化锁
self.lock = OS_SPINLOCK_INIT;
self.ticketNum = 30;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
}
// 卖票操作
- (void)saleTicket{
// 加锁🔐🔐
OSSpinLockLock(&_lock);
self.ticketNum -= 1;
NSLog(@"还剩%ld张票,currentThread - %@",self.ticketNum,[NSThread currentThread]);
// 解锁🔓🔓
OSSpinLockUnlock(&_lock);
}
结果输出:
2019-10-12 17:02:55.774748+0800 tianran[16628:4429441] 还剩29张票,currentThread - <NSThread: 0x2834ed5c0>{number = 5, name = (null)}
2019-10-12 17:02:55.774955+0800 tianran[16628:4429445] 还剩28张票,currentThread - <NSThread: 0x28341c080>{number = 6, name = (null)}
2019-10-12 17:02:55.775065+0800 tianran[16628:4429445] 还剩27张票,currentThread - <NSThread: 0x28341c080>{number = 6, name = (null)}
2019-10-12 17:02:55.775156+0800 tianran[16628:4429443] 还剩26张票,currentThread - <NSThread: 0x28349a540>{number = 3, name = (null)}
2019-10-12 17:02:55.775237+0800 tianran[16628:4429443] 还剩25张票,currentThread - <NSThread: 0x28349a540>{number = 3, name = (null)}
2019-10-12 17:02:55.775301+0800 tianran[16628:4429443] 还剩24张票,currentThread - <NSThread: 0x28349a540>{number = 3, name = (null)}
2019-10-12 17:02:55.775368+0800 tianran[16628:4429443] 还剩23张票,currentThread - <NSThread: 0x28349a540>{number = 3, name = (null)}
2019-10-12 17:02:55.775433+0800 tianran[16628:4429443] 还剩22张票,currentThread - <NSThread: 0x28349a540>{number = 3, name = (null)}
2019-10-12 17:02:55.775513+0800 tianran[16628:4429445] 还剩21张票,currentThread - <NSThread: 0x28341c080>{number = 6, name = (null)}
2019-10-12 17:02:55.775578+0800 tianran[16628:4429445] 还剩20张票,currentThread - <NSThread: 0x28341c080>{number = 6, name = (null)}
2019-10-12 17:02:55.775643+0800 tianran[16628:4429445] 还剩19张票,currentThread - <NSThread: 0x28341c080>{number = 6, name = (null)}
2019-10-12 17:02:55.775717+0800 tianran[16628:4429441] 还剩18张票,currentThread - <NSThread: 0x2834ed5c0>{number = 5, name = (null)}
2019-10-12 17:02:55.775781+0800 tianran[16628:4429441] 还剩17张票,currentThread - <NSThread: 0x2834ed5c0>{number = 5, name = (null)}
2019-10-12 17:02:55.775911+0800 tianran[16628:4429441] 还剩16张票,currentThread - <NSThread: 0x2834ed5c0>{number = 5, name = (null)}
2019-10-12 17:02:55.775983+0800 tianran[16628:4429441] 还剩15张票,currentThread - <NSThread: 0x2834ed5c0>{number = 5, name = (null)}
这里要注意,我们使用的是同一个锁对象啊,如果用多个,肯定也没效果啊。
卖票的问题,我们针对的是同一个操作来处理的,而存钱取钱的问题,涉及到了两个操作(存钱和取钱),首先要明确问题,加锁机制是为了解决从多条线程同时访问共享资源所产生的数据问题,无论这些线程里面执行的是什么操作,如果这些操作影响了共享资源,不能同时进行的话,那么应该对这些操作使用同一个锁对象进行加锁。
所以,我们应该对存钱操作和取钱操作使用相同的锁对象进行加锁。
代码如下:
#import <libkern/OSAtomic.h>
@interface ViewController ()
// 总钱数
@property (nonatomic, assign) NSInteger money;
// 锁
@property (nonatomic, assign) OSSpinLock lock;
@end
- (void)moneyTest{
// 初始化锁
self.lock = OS_SPINLOCK_INIT;
self.money = 1000;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 10; i++) {
[self saveMoney];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 10; i++) {
[self drawMoney];
}
});
}
// 存钱
- (void)saveMoney{
sleep(0.5);
OSSpinLockLock(&_lock);
NSInteger oldMoney = self.money;
oldMoney += 50;
self.money = oldMoney;
NSLog(@"存了50元,账户余额 %ld,当前线程 %@",self.money,[NSThread currentThread]);
OSSpinLockUnlock(&_lock);
}
// 取钱
- (void)drawMoney{
sleep(0.5);
OSSpinLockLock(&_lock);
NSInteger oldMoney = self.money;
oldMoney -= 20;
self.money = oldMoney;
NSLog(@"取了20元,账户余额 %ld,当前线程 %@",self.money,[NSThread currentThread]);
OSSpinLockUnlock(&_lock);
}
输出结果:
2019-10-12 17:14:44.362398+0800 tianran[16653:4433835] 存了50元,账户余额 1050,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.362540+0800 tianran[16653:4433832] 取了20元,账户余额 1030,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.362640+0800 tianran[16653:4433835] 存了50元,账户余额 1080,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.362711+0800 tianran[16653:4433832] 取了20元,账户余额 1060,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.362790+0800 tianran[16653:4433835] 存了50元,账户余额 1110,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.362923+0800 tianran[16653:4433832] 取了20元,账户余额 1090,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.363015+0800 tianran[16653:4433835] 存了50元,账户余额 1140,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.363078+0800 tianran[16653:4433832] 取了20元,账户余额 1120,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.363152+0800 tianran[16653:4433835] 存了50元,账户余额 1170,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.363206+0800 tianran[16653:4433832] 取了20元,账户余额 1150,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.363278+0800 tianran[16653:4433835] 存了50元,账户余额 1200,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.363335+0800 tianran[16653:4433832] 取了20元,账户余额 1180,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.363405+0800 tianran[16653:4433835] 存了50元,账户余额 1230,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.363458+0800 tianran[16653:4433832] 取了20元,账户余额 1210,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.363555+0800 tianran[16653:4433835] 存了50元,账户余额 1260,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.363723+0800 tianran[16653:4433835] 存了50元,账户余额 1310,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.363770+0800 tianran[16653:4433835] 存了50元,账户余额 1360,当前线程 <NSThread: 0x28118a780>{number = 5, name = (null)}
2019-10-12 17:14:44.363889+0800 tianran[16653:4433832] 取了20元,账户余额 1340,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.364014+0800 tianran[16653:4433832] 取了20元,账户余额 1320,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
2019-10-12 17:14:44.364129+0800 tianran[16653:4433832] 取了20元,账户余额 1300,当前线程 <NSThread: 0x281189080>{number = 3, name = (null)}
这次就没问题了,过程和结果都没有问题。
上面有提到,OSSpinLock是自旋锁,那么为什么叫做自旋锁呢?
自旋锁的原理是当已经被别的线程加锁了,加锁失败的时候,让线程处于忙等的状态,以此让线程停留在临界区(需要加锁的代码段)之外,一旦加锁成功,线程便可以进入临界区进行对共享资源操作。
让线程阻塞有两种方法:
- 让线程休眠,RunLoop里面用到的mach_msg()实现的效果就是这一种,它借助系统内核指令,🙆线程停下来,CPU不再分配资源给线程,因此不会再执行任何一句汇编指令,我们后面要介绍的互斥所,也是属于这种,它的底层汇编调用了一个系统函数syscall使得线程进入休眠
- 自旋锁的忙等,本质上是一个while循环,不断的去判断加锁条件,一旦当前已经进入临界区(加锁代码块)的线程完成了操作,解开锁之后,等待锁的线程便可以成功加锁,再次进入临界区。自旋锁其实并没有真正让线程停下来,线程只不过是暂时被困在while循环里面,CPU还是在不断的分配资源去处理它的汇编指令的(while循环的汇编指令)。
现在苹果已经建议开发者停止使用自旋锁了,自旋锁为什么被抛弃呢?
因为在线程优先级的作用下,会产生【优先级反转】,使得自旋锁卡住,因此它不再安全了。
大神写的文章:不再安全的OSSpinLock https://blog.ibireme.com/2016/01/16/spinlock_is_unsafe_in_ios/
我们知道,计算机的CPU在同一时间,只能处理一条线程,对于单CPU来说,线程的并发,实际上是一种假象,是系统让CPU一很小的时间间隔在线程之间来回切换,所以看上去多条线程好像是在同时进行的。到了多核CPU时代,确实可以实现真正的线程并发,但是CPU核心数毕竟是有限的,而程序内部的线程数量通常肯定是远大于CPU数量的,因此,很多情况下我们面对的还是单CPU处理多线程的情况。基于这种场景,需要了解一个概念叫做线程优先级,CPU会将尽可能多的时间(资源)分配给优先级高的线程,我们用下图来展示一下所谓的优先级反转问题:
image.png
低优先级的线程A获得锁并访问共享资源,这时一个高优先级的线程B也尝试获得这个锁,它会处于spin lock的忙等状态从而占用大量CPU,此时低优先级的线程A无法与高优先级线程B争夺CPU时间,从而导致线程A任务迟迟完不成,无法释放lock,所以会出现卡住的问题。
自旋锁的while循环本质,使得线程并没有停下来,一般情况下,一条线程等待锁的时间不会太长,选用自旋锁来阻塞线程所消耗的CPU资源,要小于线程的休眠和唤醒所带来的CPU资源开销,因此自选锁是一种效率很高的加锁机制,但是优先级反转问题使得自旋锁不再安全,锁的最终目的是安全不是效率,所以苹果放弃了OSSpinLock。
在 iOS 10/macOS 10.12 发布时,苹果提供了新的 os_unfair_lock 作为 OSSpinLock 的替代,并且将 OSSpinLock 标记为了 Deprecated。
另外为什么RunLoop要选择真正的线程休眠呢?因为对于App来说,可能处于长时间的搁置状态,而没有任何用户行为发生,不需要CPU管,对于这种场景,当然是让线程休眠更为节约性能。
3.2 os_unfair_lock
苹果建议从iOS10.0之后,使用os_unfair_lock代替OSSpinLock,现在我们就去看一下如何使用:
先导入头文件
#import <os/lock.h>
使用的API:
os_unfair_lock lock = OS_UNFAIR_LOCK_INIT;
初始化锁对象lock
os_unfair_lock_trylock(&lock);
尝试加锁,加锁成功继续,加锁失败返回,继续执行后面的代码,不阻塞线程
os_unfair_lock_lock(&lock);
加锁,加锁失败会阻塞线程进行等待
os_unfair_lock_unlock(&lock);
解锁
它的使用和OSSpinLock的方法一样,这里就不再举例了,苹果为了解决OSSpinLock的优先级反转问题,在os_unfair_lock中摒弃了忙等方式,使用让线程真正休眠的方式,来阻塞线程,也就从根本上解决了问题。
3.3 pthread_mutex
pthread_mutex 来自 pthread,是一个跨平台的解决方案,mutex意为互斥锁,等待锁的线程会处于休眠状态,它有如下API:
需要先导入 #import <pthread.h>
初始化锁的属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NOMAL);
初始化锁
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, &attr);
尝试加锁
pthread_mutex_trylock(&mutex);
加锁
pthread_mutex_lock(&mutex);
解锁
pthread_mutex_unlock(&mutex);
销毁相关资源
pthread_mutexattr_destroy(&attr);
pthread_mutex_destroy(&attr);
我们先介绍一下pthread_mutex的初始化方法:
int pthread_mutex_init(pthread_mutex_t * __restrict,
const pthread_mutexattr_t * _Nullable __restrict);
- pthread_mutex_t * __restrict
要进行初始化的锁对象 - const pthread_mutexattr_t * _Nullable __restrict
锁对象的属性
由于第二个参数是锁对象的属性,所以我们还需要专门生成属性对象,通过 定义属性对象 -> 初始化属性对象 -> 设置属性种类 3步来完成,属性的类别有以下几类:
#define PTHREAD_MUTEX_NORMAL 0 // 普通互斥锁
#define PTHREAD_MUTEX_ERRORCHECK 1 // 检查错误锁,不常用
#define PTHREAD_MUTEX_RECURSIVE 2 // 递归互斥锁,下面会有介绍
#define PTHREAD_MUTEX_DEFAULT PTHREAD_MUTEX_NORMAL
如果我们给锁设定默认属性,那么可以用以下代码进行锁的初始化,不用再配置属性信息,其中参数NULL表示的就是初始化一个普通的互斥锁
pthread_mutex_init(mutex, NULL);
好了,我们先用卖票示例来使用一下pthread_mutex,代码如下:
#import <pthread.h>
@interface ViewController ()
@property (nonatomic, assign) NSInteger ticketNum;
@property (nonatomic, assign) pthread_mutex_t pthread_lock;
@end
- (void)ticketTest{
// 初始化锁的属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);
// 初始化锁
pthread_mutex_init(&_pthread_lock, &attr);
self.ticketNum = 30;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
dispatch_async(queue, ^{
for (NSInteger i = 0; i < 5; i++) {
[self saleTicket];
}
});
}
// 卖票操作
- (void)saleTicket{
// 加锁🔐🔐
pthread_mutex_lock(&_pthread_lock);
self.ticketNum -= 1;
NSLog(@"还剩%ld张票,currentThread - %@",self.ticketNum,[NSThread currentThread]);
// 解锁🔓🔓
pthread_mutex_unlock(&_pthread_lock);
}
-(void)dealloc{
pthread_mutex_destroy(&_pthread_lock);
}
输出结果就不贴了,是没有问题的,太占地方了。
3.4 互斥递归锁
请看下面的代码场景:
-(void)otherTest {
NSLog(@"%s",__func__);
[self otherTest2];
}
-(void)otherTest2 {
NSLog(@"%s",__func__);
}
如果正常调用 otherTest 这个方法,结果如下:
[ViewController otherTest]
[ViewController otherTest2]
如果这两段代码都需要保证线程安全,我们通过加互斥锁,来看下效果:
-(void)otherTest {
//初始化属性
pthread_mutexattr_init(&_attr);
pthread_mutexattr_settype(&_attr, PTHREAD_MUTEX_NORMAL);
//初始化锁
pthread_mutex_init(&_mutex, &_attr);
pthread_mutex_lock(&_mutex);
NSLog(@"%s",__func__);
[self otherTest2];
pthread_mutex_unlock(&_mutex);
}
-(void)otherTest2 {
pthread_mutex_lock(&_mutex);
NSLog(@"%s",__func__);
pthread_mutex_unlock(&_mutex);
}
输出结果:
-[ViewController otherTest]
像上面的代码一样,两个方法都加上同一把锁,可以看到调用otherTest方法会导致线程卡在该方法里面,只完成了打印代码的执行,就不继续往下走了,为啥呢?看下图:
死锁
我们可以从上面代码中看到,在otherTest方法中,先加锁了, [self otherTest2] 在加锁之后调用,但是otherTest2方法中,此时还是加锁的状态,所以otherTest2方法一直不执行,由于otherTest方法中,解锁操作是在otherTest2方法执行之后,所以造成死锁。
要解决这个问题很简单,我们分别给两个方法加上不同的锁对象就可以解决了。
但是,如果在开发中如果碰到需要给 递归函数 加锁,如下面所示:
-(void)otherTest {
pthread_mutex_lock(&_mutex);
NSLog(@"%s",__func__);
//业务逻辑
[self otherTest];
pthread_mutex_unlock(&_mutex);
}
这样就无法通过不同的锁对象来加锁了,只要使用相同的锁对象,就会出现死锁。针对这种情况,ptherad给我们提供了递归锁来解决这个问题。要想使用递归锁,我们只需要在初始化属性的时候,选择递归锁属性即可,其他的使用步骤跟普通互斥锁没有区别,如下:
pthread_mutexattr_settype(&_attr, PTHREAD_MUTEX_RECURSIVE);
那么递归锁是如何避免死锁的呢?对于同一个锁对象来说,允许重复的加锁,重复的解锁,因为对于一个有出口的递归函数来说:
函数的调用次数 = 函数的退出次数
因此加锁次数pthread_mutex_lock和解锁的次数pthread_mutex_unlock是相等的,所以递归函数结束时,所有的锁都会被解开。
但是递归锁只是针对在同一个线程里面可以重复加锁和解锁。
互斥条件锁 pthread_cond_t
pthread_mutex_t mutex; 定义一个锁对象
pthread_mutex_init(&mutex, NULL); 初始化锁对象
pthread_cond_t condition; 定义一个条件对象
pthread_cond_init(&condition, NULL); 初始化条件对象
pthread_cond_wait(&condition, &mutex); 等待条件
pthread_cond_signal(&condition); 激活一个等待该条件的线程
pthread_cond_broadcast(&condition); 激活所有等待条件的线程
pthread_mutex_destroy(&mutex); 销毁锁对象
pthread_cond_destroy(&condition); 销毁条件对象
为了解释互斥锁条件的作用,我们来设计一种场景案例:
- 在 remove 方法里对数组 dataArr 进行删除元素操作
- 在 add 方法里面对dataArr 进行元素添加操作
- 并且要求,如果 dataArr 的元素个数为0,则不能进行删除操作
代码如下:
@interface ViewController ()
@property (nonatomic, strong) NSMutableArray *dataArr;
// 锁对象
@property (nonatomic, assign) pthread_mutex_t pthread_lock;
// 条件对象
@property (nonatomic, assign) pthread_cond_t cond;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 初始化属性
pthread_mutexattr_t _attr;
pthread_mutexattr_init(&_attr);
pthread_mutexattr_settype(&_attr, PTHREAD_MUTEX_NORMAL);
// 初始化锁
pthread_mutex_init(&_pthread_lock, &_attr);
// 初始化条件
pthread_cond_init(&_cond, NULL);
pthread_mutexattr_destroy(&_attr);
// 初始化数组
self.dataArr = [NSMutableArray array];
[self dataArrTest];
}
- (void)dataArrTest{
// 开启remove 操作,此时dataArr为空
NSThread *removeThread = [[NSThread alloc] initWithTarget:self selector:@selector(remove) object:nil];
[removeThread setName:@"RemoveThread"];
[removeThread start];
// 开启add操作
NSThread *addThread = [[NSThread alloc] initWithTarget:self selector:@selector(add) object:nil];
[addThread setName:@"AddThread"];
[addThread start];
}
// 移除元素
- (void)remove{
// 加锁
pthread_mutex_lock(&_pthread_lock);
NSLog(@"加锁成功,线程:%@,remove操作开始",[NSThread currentThread]);
if (!self.dataArr.count) {
// 进行条件等待
NSLog(@"dataArr没有元素,开始等待");
pthread_cond_wait(&_cond, &_pthread_lock);
NSLog(@"接收到条件更新信号,dataArr有了元素,继续remove操作");
}
[self.dataArr removeLastObject];
NSLog(@"remove成功,dataArr剩余元素个数:%ld",self.dataArr.count);
// 解锁
pthread_mutex_unlock(&_pthread_lock);
NSLog(@"remove 解锁成功,线程:%@,线程结束\n",[NSThread currentThread]);
}
// 添加元素
- (void)add{
// 加锁
pthread_mutex_lock(&_pthread_lock);
NSLog(@"加锁成功,线程:%@,add操作开始",[NSThread currentThread]);
sleep(2);
[self.dataArr addObject:@"111"];
NSLog(@"add成功,dataArr剩余元素个数:%ld,发送条件信号",self.dataArr.count);
// 发送条件信号
pthread_cond_signal(&_cond);
// 解锁
pthread_mutex_unlock(&_pthread_lock);
NSLog(@"add 解锁成功,线程:%@,线程结束",[NSThread currentThread]);
}
-(void)dealloc{
pthread_mutex_destroy(&_pthread_lock);
}
@end
输出结果:
2019-10-14 15:14:52.929491+0800 tianran[2004:431723] 加锁成功,线程:<NSThread: 0x283f8cd40>{number = 6, name = RemoveThread},remove操作开始
2019-10-14 15:14:52.929557+0800 tianran[2004:431723] dataArr没有元素,开始等待
2019-10-14 15:14:52.929641+0800 tianran[2004:431724] 加锁成功,线程:<NSThread: 0x283f8c9c0>{number = 7, name = AddThread},add操作开始
2019-10-14 15:14:54.935014+0800 tianran[2004:431724] add成功,dataArr剩余元素个数:1,发送条件信号
2019-10-14 15:14:54.935672+0800 tianran[2004:431723] 接收到条件更新信号,dataArr有了元素,继续remove操作
2019-10-14 15:14:54.935790+0800 tianran[2004:431724] add 解锁成功,线程:<NSThread: 0x283f8c9c0>{number = 7, name = AddThread},线程结束
2019-10-14 15:14:54.936061+0800 tianran[2004:431723] remove成功,dataArr剩余元素个数:0
2019-10-14 15:14:54.936415+0800 tianran[2004:431723] remove 解锁成功,线程:<NSThread: 0x283f8cd40>{number = 6, name = RemoveThread},线程结束
从案例以及运行结果分析,互斥锁的条件pthread_cond_t可以在线程加锁之后,如果条件不达标,暂停线程,等到条件符合标准,继续执行线程,请看下图:
image.png
总结一下pthread_cond_t的作用:
- 首先在A线程内碰到业务逻辑无法往下执行的时候,调用pthread_cond_wait(),这句代码首先会解锁当前线程,然后休眠当前线程以等待条件信号
- 此时,锁已经解开,那么值钱等待锁的B线程可以成功加锁,执行它后面的逻辑,由于B线程内的某些操作完成后可以出发A的运行条件,此时从B线程通过pthread_cond_signal(&_cond)向外发出条件信号
- A线程的收到了条件信号就会被pthread_cond_t唤醒,一旦B线程解锁之后,pthread_cond_t会在A线程内重新加锁,继续A线程的后续操作,并最终解锁。从前到后,有三次加锁,三次解锁
- 通过pthread_cond_t就实现了一种线程与线程之间的依赖关系,实际开发中我们会有不少场景需要用到这种跨线程依赖关系
3.5 NSLock NSRecursiveLock NSCondition
上面我们了解的 mutex普通锁,mutex递归锁,mutex条件锁,都是基于C语言的API,苹果在此基础上,进行了一层面向对象的封装:
- NSLock 封装了 pthread_mutex_t (attr = 普通)
- NSRecursiveLock 封装了 pthread_mutex_t (attr = 递归)
- NSCondition 封装了 pthread_mutex_t + pthread_cond_t
由于底层是pthread_mutex,这里不再通过代码案例演示,下面列举一下相关API使用方法:
//普通锁
NSLock *lock = [[NSLock alloc] init];
[lock lock];
[lock unlock];
//递归锁
NSRecursiveLock *rec_lock = [[NSRecursiveLock alloc]
[rec_lock lock];
[rec_lock unlock];init];
//条件锁
NSCondition *condition = [[NSCondition alloc] init];
[self.condition lock];
[self.condition wait];
[self.condition signal];
[self.condition unlock];
3.6 NSConditionLock
苹果基于NSCondition又进一步封装了NSConditionLock,该锁允许我们在锁中设定条件具体条件值,有了这个功能,我们可以更加方便的管理多条线程的依赖关系和前后执行顺序,首先看一下相关API:
- (BOOL)tryLock;
- (BOOL)lockBeforeDate:(NSDate *)limit;
特色功能:
- (instancetype)initWithCondition:(NSInteger)condition NS_DESIGNATED_INITIALIZER;
@property (readonly) NSInteger condition;
- (void)lockWhenCondition:(NSInteger)condition;
- (BOOL)tryLockWhenCondition:(NSInteger)condition;
- (void)unlockWithCondition:(NSInteger)condition;
- (BOOL)lockWhenCondition:(NSInteger)condition beforeDate:(NSDate *)limit;
接下来通过案例来说明它的功能:
@interface ViewController ()
@property (nonatomic, strong) NSConditionLock *conditionLock;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.conditionLock = [[NSConditionLock alloc] init];
[self dataArrTest];
}
- (void)dataArrTest{
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
[self.conditionLock lock];
NSLog(@"__one");
sleep(1);
[self.conditionLock unlockWithCondition:2];
});
dispatch_async(queue, ^{
[self.conditionLock lockWhenCondition:2];
NSLog(@"__two");
sleep(1);
[self.conditionLock unlockWithCondition:3];
});
dispatch_async(queue, ^{
[self.conditionLock lockWhenCondition:3];
NSLog(@"__three");
sleep(1);
[self.conditionLock unlock];
});
}
@end
代码输出:
__one __two __three
全局并发队列,异步操作,这里就是使用NSConditionLock达到了线程依赖的功能,使用NSOperation也可以,我们用图说明一下:
image.png
3.7 dispatch_semaphore
GCD提供了dispatch_semaphore方案来处理多线程同步问题。移步另一篇文章:
https://www.jianshu.com/p/4789c32bdae7
3.8 @synchronized
这个使用很简单,也比较常见:
@synchronized (lockObj) {
/*
加锁代码(临界区)
*/
}
但是它是所有线程同步方案里面性能最差的。@synchronized内部封装了数组,字典(哈希表)、C++的数据结构等一系列复杂数据结构,导致它的实际性能特别低下,实际上是性能最低的线程同步,虽然你可能在一些牛逼框架里面看到过它被使用,但是如果你不是对底层特别熟练的话,还是按照苹果的建议,少用为妙,因为它真的很浪费性能。
我们对iOS的各种线程同步方案体验了一下:
- OSSpinLock由于其不休眠特性,所以它的效率是非常高的,但是由于安全问题,苹果建议我们使用os_unfair_lock取而代之,并且效率还要高于前者。
- pthread_mutex是一种跨平台的解决方案,性能也不错。当然还有苹果的GCD解决方案,也是挺不错的。
- 对于NS开头的那些OC下的解决方案,虽然本质也还是基于pthread_mutex的封装,但是由于多了一些面向对象的操作开销,效率不免要下降。
- 性能最差的是@synchronized方案,虽然它的使用是最简单的,但因为它的底层封装了过于复杂的数据结构,导致了性能低下。
使用推荐如下: - os_unfair_lock(推荐🌟🌟🌟🌟🌟)
- OSSpinLock(不安全⚠️⚠️)
- dispatch_semaphore(推荐🌟🌟🌟🌟🌟)
- pthread_mutex(推荐🌟🌟🌟🌟)
- dispatch_queue(DISPATCH_QUEUE_SERIAL)(推荐🌟🌟🌟)
- NSLock(🌟🌟🌟)
- NSCondition(🌟🌟🌟)
- pthread_mutex(recursive)(🌟🌟)
- NSRecursiveLock(🌟🌟)
- NSConditionLock(🌟🌟)
- @synchronized(最不推荐)
4. 自旋锁和互斥锁的对比
4.1 什么情况下选择自旋锁更好?
自旋锁的特点:效率高、安全性不足、占用CPU资源大,因此选择自旋锁依据原则如下:
- 预计线程等待锁的时间很短
- 加锁的代码(临界区)经常被调用,但是竞争的情况发生概率很小,对安全性要求不高
- CPU资源不紧张
- 多核处理器
4.2 什么情况下选择互斥锁更好?
互斥锁特点:安全性突出、占用CPU资源小,休眠/唤醒过程要消耗CPU资源,因此选择互斥锁依据原则如下:
- 预计线程等待锁的时间比较长
- 单核处理器
- 临界区有IO操作
- 临界区代码复杂或者循环量大
- 临界区的竞争非常激烈,对安全性要求高
4.3 为什么iOS中几乎不用atomic?
atomic是用于保证属性的setter、getter方法的原子性操作的,本质就是在getter和setter内部增加线程同步的锁,用的锁实质上也是os_unfair_lock。
但是atomic只是保证读写操作的线程同步,对于可变数组、可变字典来说,如果你使用atomic修饰,在添加元素的时候,并不是线程安全的:
NSMutableArray *arr = self.dataArr;//getter方法是安全的
for (int i = 0; i<5; i++) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[arr addObject:@"1"];//这里会有多线程操作_dataArr,atomic无法保证这里的线程同步
});
}
所以说atomic并不能完全保证多线程安全问题。
我们几乎不会用到atomic,因为property在iOS代码中调用的太频繁了,会导致锁的过度使用,消耗CPU资源,所以我们只需要针对具体会出现多线程隐患的地方加锁就行了,需要加锁的时候再去加。
4.4 多线程读写安全
在上面存取钱的示例中,存、和取其实就是对共享资源的读和写,假如我们有如下两个操作分别只包含读操作和写操作:
- (void)read {
sleep(1);
NSLog(@"read");
}
- (void)write
{
sleep(1);
NSLog(@"write");
}
其实读操作的目的,只是取出数据,并不会修改数据,所以多线程同时进行 读 操作是没问题的,不需要考虑线程同步的问题,写操作是导致多线程安全问题的根本因素。所以为了读写安全,解决方案其实就是多读单写:
- 要求1:同一时间,只能有1个线程进行写的操作
- 要求2:同一时间,允许有多个线程进行读的操作
- 要求3:同一时间,不允许既读又写,就是说读操作和写操作之间是互斥关系
iOS有两种方案可以实现上述的读写安全需求
- pthread_rwlock:读写锁
- dispatch_barrier_async:异步栅栏调用
- pthread_rwlock
使用pthread_rwlock,等待锁的线程会进入休眠,API如下:
//初始化锁
pthread_rwlock_t lock;
pthread_rwlock_init(&lock, NULL);
//读操作加锁
pthread_rwlock_rdlock(&lock);
//读操作尝试加锁
pthread_rwlock_tryrdlock(&lock);
//写操作加锁
pthread_rwlock_wrlock(&lock);
//写操作尝试加锁
pthread_rwlock_trywrlock(&lock);
//解锁
pthread_rwlock_unlock(&lock);
//销毁锁
pthread_rwlock_destroy(&lock);
- dispatch_barrier_async
使用dispatch_barrier_async有一个注意点,这个函数接受的并发队列参数必须是你自己手动创建的(dispatch_queue_create),如果接受的是一个串行队列或者是一个全局并发队列,那么这个函数的效果等同于dispatch_async函数。
具体介绍,可以看另外一篇文章,里面有对于GCD常用方法的介绍:
https://www.jianshu.com/p/3f9910293401
为了更好的学习,所以选择记录下来。
网友评论