iOS-多线程2-线程安全、OSSpinLock

一. 安全隐患

利用多线程异步可以同时做不同的事情,效率更高,但是这样也会有安全隐患。

造成安全隐患的原因:
一块资源可能会被多个线程共享,也就是多个线程可能会访问同一块资源,比如多个线程访问同一个对象、同一个变量、同一个文件。当多个线程访问同一块资源时,很容易引发数据错乱和数据安全问题。

两个比较经典的问题:

存钱取钱.png
卖票.png

下面用代码来验证卖票问题:

- (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有问题。

分析造成安全隐患的原因:

多线程安全隐患分析.png

如图,按理说两个线程对17分别进行两次+1操作,应该是19,最后是18,就是因为这个安全隐患造成的。

那么如何解决安全隐患呢?

二. 解决方案

使用线程同步技术(同步,就是协同步调,按预定的先后次序进行)解决线程的安全隐患,常见的线程同步技术是:加锁

加锁实现原理如下图,就不解释了:

加锁解释.png

三. 加锁方案

OSSpinLock 自旋锁
os_unfair_lock
pthread_mutex
dispatch_semaphore 信号量
dispatch_queue(DISPATCH_QUEUE_SERIAL) 串行队列
NSLock
NSRecursiveLock
NSCondition
NSConditionLock
@synchronized

1. OSSpinLock

OSSpinLock叫做”自旋锁”,等待锁的线程会处于忙等(busy-wait)状态,一直占用着CPU资源。

  1. 目前已经不再安全,可能会出现优先级反转问题,也就是,如果等待锁的线程优先级较高,它会一直占用着CPU资源,优先级低的线程就无法释放锁。
  2. 需要导入头文件#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)}

说明加锁成功。
  1. OSSpinLockLock(&_lock)这行代码里面做的事情:加锁之前会判断这把锁有没有在加锁,如果在加锁就阻塞在OSSpinLockLock(&_lock)这一行,等这把锁解锁完,再加锁。
  2. 卖票只需要saleTicket方法里面加锁,存钱取钱需要在saveMoney和drawMoney里面都要加锁,而且要使用同一把锁。
  3. 自旋锁就是下面方式②,类似于写了个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:

关于上面的代码有两个知识点:

  1. static的作用
    static修饰局部变量会延长局部变量生命周期,至程序结束
    static修饰全局变量,那么全局变量作用域只在本文件

自己思考一下,为什么要用static修饰上面的全局变量和局部变量?

  1. 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地址:线程安全、自旋锁

你可能感兴趣的:(iOS-多线程2-线程安全、OSSpinLock)