iOS多线程之--线程安全(线程锁)

2,130 阅读33分钟


iOS多线程demo

iOS多线程之--NSThread

iOS多线程之--GCD详解

iOS多线程之--NSOperation

iOS多线程之--线程安全(线程锁)

iOS多线程相关面试题



1. 什么是线程安全?

如果一段代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。一般来说当多个线程访问同一块资源(同一个对象、同一个变量、同一个文件)时,很容易引发数据错乱和数据安全问题。

比如说有个售票系统,开2个线程(相当于2个售票窗口)同时进行售票,售票过程是这样的:首先从数据库中取出余票数量,如果余票数量大于0,那么就进行售票,售1张票耗时1秒钟,出票成功后再将票数减一然后存进数据库。那2个线程同时售票会有什么问题呢?假设余票数量是10,我们现在模拟一下售票过程:

  • 0.0秒时线程1(窗口1)从数据库取出余票数量10,然后进行售票操作(需要耗时1秒)。
  • 0.5秒时线程2(窗口2)从数据库取出余票数量10(因为此时窗口1的售票还没完成,所以数据库中余票数仍然是10),然后进行售票操作。
  • 1.0秒时窗口1售票完成,然后将9(10-1=9)存入数据库,现在数据库中余票数量是9。
  • 1.5秒时窗口2售票完成,然后将之前取出的余票数量10减去1后存入数据库中,数据库中余票数量仍然是9。

从上面已经可以看出问题了,2个线程总共卖出了2张票,结果数据库中还剩余9张票,与我们的预期不符,所以这不是线程安全的。下面我们来看实际代码演示:

- (void)noLock{
    self.ticketCount = 10;
    
    // 线程1(窗口1)
    NSThread *thread1 = [[NSThread alloc] initWithBlock:^{
        for (NSInteger i = 0; i < 5; i++) {
            [self noLockSaleTicket];
        }
    }];
    thread1.name = @"窗口1";
    [thread1 start];
    
    // 线程2(窗口2)
    NSThread *thread2 = [[NSThread alloc] initWithBlock:^{
        for (NSInteger i = 0; i < 5; i++) {
            [self noLockSaleTicket];
        }
    }];
    thread2.name = @"窗口2";
    [thread2 start];
}

// 不加锁时售票过程
- (void)noLockSaleTicket{
    NSInteger oldCount = self.ticketCount; // 取出余票数量
    if (oldCount > 0) {
        [NSThread sleepForTimeInterval:1.0f]; // 模拟售票耗时1秒
        self.ticketCount = --oldCount; // 售出一张票后更新剩余票数
    }
    NSLog(@"剩余票数:%ld--%@",self.ticketCount,[NSThread currentThread]);
}

****************打印结果****************
2020-01-01 10:31:11.260165+0800 MultithreadingDemo[51984:5695718] 剩余票数:9--<NSThread: 0x60000373b600>{number = 9, name = 窗口2}
2020-01-01 10:31:11.260165+0800 MultithreadingDemo[51984:5695717] 剩余票数:9--<NSThread: 0x60000373b880>{number = 8, name = 窗口1}
2020-01-01 10:31:12.263126+0800 MultithreadingDemo[51984:5695718] 剩余票数:8--<NSThread: 0x60000373b600>{number = 9, name = 窗口2}
2020-01-01 10:31:12.263152+0800 MultithreadingDemo[51984:5695717] 剩余票数:8--<NSThread: 0x60000373b880>{number = 8, name = 窗口1}
2020-01-01 10:31:13.263691+0800 MultithreadingDemo[51984:5695717] 剩余票数:7--<NSThread: 0x60000373b880>{number = 8, name = 窗口1}
2020-01-01 10:31:13.263693+0800 MultithreadingDemo[51984:5695718] 剩余票数:7--<NSThread: 0x60000373b600>{number = 9, name = 窗口2}
2020-01-01 10:31:14.264340+0800 MultithreadingDemo[51984:5695717] 剩余票数:6--<NSThread: 0x60000373b880>{number = 8, name = 窗口1}
2020-01-01 10:31:14.264340+0800 MultithreadingDemo[51984:5695718] 剩余票数:6--<NSThread: 0x60000373b600>{number = 9, name = 窗口2}
2020-01-01 10:31:15.265322+0800 MultithreadingDemo[51984:5695717] 剩余票数:5--<NSThread: 0x60000373b880>{number = 8, name = 窗口1}
2020-01-01 10:31:15.265322+0800 MultithreadingDemo[51984:5695718] 剩余票数:5--<NSThread: 0x60000373b600>{number = 9, name = 窗口2}

2. 如何确保线程安全?

我们是采用线程同步技术来解决多线程的安全隐患问题,所谓同步,就是协同步调,按预定的先后次序进行。常见的线程同步技术就是加锁。从上面案例可以看出,导致出现问题的原因就是2个线程同时在对数据库进行读写操作,加锁就是在有线程正在进行读写操作时将读写操作的代码锁住,这样其他线程就不能在这个时候进行读写操作,等前面的线程完成操作后再解锁,然后后面的线程才能进行读写操作。换句话说加锁就是让某段代码在同一时刻只能有一个线程在执行。

在iOS开发中,有多种线程同步方案来确保线程安全,下面来一一介绍:

2.1 OSSpinLock(自旋锁)

所谓自旋锁,就是等待锁的线程是处于忙等状态。所谓忙等,我们可以理解为就是一个while循环,只要发现锁是加锁状态就一直循环,直到发现锁处于未加锁状态才停止循环。所以自旋锁一直占用着CPU资源,不过自旋锁的效率是非常高的,一旦锁被释放,等待锁的线程立马就可以感知到。一般来说,如果被加锁的代码块所需的执行时间非常短就可以使用自旋锁,但是如果是非常耗时的话就不建议使用自旋锁了,因为等待锁的线程一直忙等非常耗CPU资源。

自旋锁的实现原理比较简单,我们可以定义一个BOOL类型变量isLock用来表示锁的状态,其初始化为NO表示未加锁。如果要加锁的代码块用A表示,当线程1要执行A之前先判断锁的状态是未加锁状态,所以线程1获取到锁并将isLock置为YES来加锁,然后开始执行A代码块,执行结束后将isLock置为NO来解锁。如果线程1正在执行代码块A时线程2也想要执行代码块A,结果发现现在是加锁状态,所以线程2开始while循环进行忙等,直到线程1解锁线程2才结束while循环而获得锁。忙等、加锁和解锁可以用如下的伪代码来表示:

// while循环进行忙等,循环体里面什么都不用做,只要是加锁状态就一直循环
while(_isLock){
    
}

// 执行代码块A之前加锁
_isLock = YES;

// 执行代码块A
……

// 执行完代码块A后解锁
_isLock = NO;

iOS中的OSSpinLock就是自旋锁,这是一个C语言实现的锁,使用时需要导入头文件#import <libkern/OSAtomic.h>。还是用前面的售票的例子来进行演示,由于导致线程安全问题的就是售票过程的代码块,所以我们只需对这一部分代码进行加锁解锁操作。

- (void)OSSpinLockSaleTicket{
    if (!_spinLock) { // 初始化锁
        _spinLock = OS_SPINLOCK_INIT;
    }
    
    // 加锁
    OSSpinLockLock(&_spinLock);
    
    NSInteger oldCount = self.ticketCount;
    if (oldCount > 0) {
        [NSThread sleepForTimeInterval:1.0f];
        self.ticketCount = --oldCount;
    }
    NSLog(@"剩余票数:%ld--%@",self.ticketCount,[NSThread currentThread]);
    
    // 解锁
    OSSpinLockUnlock(&_spinLock);
}

****************打印结果****************
2020-01-01 17:23:37.832503+0800 MultithreadingDemo[64676:6658463] 剩余票数:9--<NSThread: 0x600003a54b40>{number = 7, name = 窗口1}
2020-01-01 17:23:38.837188+0800 MultithreadingDemo[64676:6658463] 剩余票数:8--<NSThread: 0x600003a54b40>{number = 7, name = 窗口1}
2020-01-01 17:23:39.843327+0800 MultithreadingDemo[64676:6658463] 剩余票数:7--<NSThread: 0x600003a54b40>{number = 7, name = 窗口1}
2020-01-01 17:23:40.848290+0800 MultithreadingDemo[64676:6658463] 剩余票数:6--<NSThread: 0x600003a54b40>{number = 7, name = 窗口1}
2020-01-01 17:23:41.850766+0800 MultithreadingDemo[64676:6658463] 剩余票数:5--<NSThread: 0x600003a54b40>{number = 7, name = 窗口1}
2020-01-01 17:23:42.868658+0800 MultithreadingDemo[64676:6658464] 剩余票数:4--<NSThread: 0x600003a54c80>{number = 8, name = 窗口2}
2020-01-01 17:23:43.872599+0800 MultithreadingDemo[64676:6658464] 剩余票数:3--<NSThread: 0x600003a54c80>{number = 8, name = 窗口2}
2020-01-01 17:23:44.878190+0800 MultithreadingDemo[64676:6658464] 剩余票数:2--<NSThread: 0x600003a54c80>{number = 8, name = 窗口2}
2020-01-01 17:23:45.880620+0800 MultithreadingDemo[64676:6658464] 剩余票数:1--<NSThread: 0x600003a54c80>{number = 8, name = 窗口2}
2020-01-01 17:23:46.885194+0800 MultithreadingDemo[64676:6658464] 剩余票数:0--<NSThread: 0x600003a54c80>{number = 8, name = 窗口2}

可以看到加锁后运行结果就和我们预期的一样了。不过这种加锁方式从iOS10开始就已经被弃用了,因为这种加锁方式可能会导致出现优先级反转的问题。所谓优先级反转,就是可能会出现高优先级任务所需的资源被低优先级任务给锁住了而导致高优先级任务被阻塞,从而高优先级任务迟迟得不到调度。但其他中等优先级的任务却能抢到CPU资源。从现象上来看,好像是中优先级的任务比高优先级任务具有更高的优先权。

2.2 os_unfair_lock

从iOS10开始苹果不再建议使用OSSpinLock进行加锁,取而代之的是用os_unfair_lock进行加锁。其也是用C语言实现的,使用时需要导入头文件#import <os/lock.h>。使用方法如下:

- (void)osUnfairLockSaleTicket{
    // 初始化锁(使用dispatch_once只是为了保证锁只被初始化一次)
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _unfairLock = OS_UNFAIR_LOCK_INIT;
    });
    
    // 加锁
    os_unfair_lock_lock(&_unfairLock);
    
    NSInteger oldCount = self.ticketCount;
    if (oldCount > 0) {
        [NSThread sleepForTimeInterval:1.0f];
        self.ticketCount = --oldCount;
    }
    NSLog(@"os_unfair_lock剩余票数:%ld--%@",self.ticketCount,[NSThread currentThread]);
    
    // 解锁
    os_unfair_lock_unlock(&_unfairLock);
}

2.3 pthread_mutex(互斥锁)

和自旋锁不同的是,等待互斥锁的线程在等待过程中是处于休眠状态。一旦锁被其他线程释放,处于休眠状态的线程就会被唤醒。所以线程在等待互斥锁的过程中是不占用CPU资源的,但是唤醒线程是需要消耗一定时间的,所以互斥锁的效率要比自旋锁低。

pthread_mutex就是一种互斥锁,它是跨平台的。使用时需要导入头文件#import <pthread.h>pthread_mutex使用起来要比前面介绍的那些锁要麻烦,在初始化锁之前首先要定义一个锁的属性,然后根据锁的属性来初始化锁(初始化锁时属性参数可以直接传NULL进去,传NULL就是使用默认类型)。因为pthread_mutex可以设置几种不同类型的锁,设置属性时要指定锁的类型,锁的类型包括以下几种:

// pthread_mutex互斥锁属性的类型
 #define PTHREAD_MUTEX_NORMAL        0 // 常规的锁(默认类型)
 #define PTHREAD_MUTEX_ERRORCHECK    1 // 检查错误类型的锁(一般用不上)
 #define PTHREAD_MUTEX_RECURSIVE        2 // 递归类型的锁
 #define PTHREAD_MUTEX_DEFAULT        PTHREAD_MUTEX_NORMAL  // 默认类型为常规类型

另外,当不再需要使用锁时记得将锁和锁的属性释放(前面介绍的两种锁是没有提供销毁锁的API的),释放方法如下:

// 释放锁的属性
pthread_mutexattr_destroy(&attr);
// 释放锁
pthread_mutex_destroy(&mutexLock);

2.3.1 pthread_mutex---常规锁

像前面介绍的2种锁都是常规的锁,下面我们来看下pthread_mutex的常规锁要如何使用。

- (void)pthreadMutexLockSaleTicket{
    // 保证锁只被初始化一次
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 初始化锁的属性
        pthread_mutexattr_t attr; // 创建锁的属性
        pthread_mutexattr_init(&attr); // 初始化锁的属性
        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL); // 设置锁属性的类型
        
        // 根据锁的属性来初始化锁(属性参数传NULL也是一个常规锁)
        pthread_mutex_init(&_pthreadMutexLock, &attr);
    });
    
    // 加锁
     pthread_mutex_lock(&_pthreadMutexLock);
     
     NSInteger oldCount = self.ticketCount;
     if (oldCount > 0) {
         [NSThread sleepForTimeInterval:1.0f];
         self.ticketCount = --oldCount;
     }
     NSLog(@"pthread_mutex剩余票数:%ld--%@",self.ticketCount,[NSThread currentThread]);
     
     // 解锁
     pthread_mutex_unlock(&_pthreadMutexLock);
}

2.3.2 pthread_mutex---递归锁

pthread_mutex递归锁pthread_mutex常规锁在使用方法上是一样的,只需把属性的类型参数由PTHREAD_MUTEX_NORMAL改为PTHREAD_MUTEX_RECURSIVE就可以了。

但是到底什么是递归锁呢?什么情况下需要用到递归锁呢?说起递归,我们首先想到的就是函数的递归调用,也就是在函数内部又调用了自己。递归锁主要就是用在函数的递归调用场合的,其特点就是锁里面又加锁,换句话说就是同一把锁可以重复加多次。如果在这种场合下我们用常规锁会出现什么问题呢?我们看下下面这个例子:

- (void)test{
    // 加锁
     pthread_mutex_lock(&_pthreadMutexNotmalLock);
     
     // 加锁代码为递归调用
    static NSInteger i = 5;
    NSInteger temp = i--;
    if (temp > 0) {
        [self test];
    }
    NSLog(@"%ld",temp);
     
     // 解锁
     pthread_mutex_unlock(&_pthreadMutexNotmalLock);
}

如果某个线程调用上面这个方法的话就会死锁,不会有任何打印信息。因为这是一个常规锁,当线程第一次调用test方法时,这个线程获取到锁,此时tem>0,会第二次调用test(注意这个时候锁没有被释放),所以第二次调用test时发现锁此时是加锁状态,只能在这里等锁释放后才能继续往后执行。而第一次调用test又必须等第二次调用test结束了才能继续往下执行来释放锁,这就造成了死锁。

我们再来看看换成递归锁会怎么样:

- (void)pthreadMutexRecursiveLockTest{
    
    // 保证锁只被初始化一次
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 初始化锁的属性
        pthread_mutexattr_t attr; // 创建锁的属性
        pthread_mutexattr_init(&attr); // 初始化锁的属性
//        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL); // 设置锁属性的类型为常规锁的话就会造成死锁
        pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); // 设置锁属性的类型为递归锁
        
        // 根据锁的属性来初始化锁
        pthread_mutex_init(&_pthreadMutexRecursiveLock, &attr);
    });
    
    // 加锁
     pthread_mutex_lock(&_pthreadMutexRecursiveLock);
     
     // 加锁代码为递归调用
    static NSInteger i = 5;
    NSInteger temp = i--;
    if (temp > 0) {
        [self pthreadMutexRecursiveLockTest];
    }
    NSLog(@"pthread_mutex递归锁---%ld",temp);
     
     // 解锁
     pthread_mutex_unlock(&_pthreadMutexRecursiveLock);
}

****************打印结果****************
2020-01-02 10:28:09.961983+0800 MultithreadingDemo[59365:5508927] pthread_mutex递归锁---0
2020-01-02 10:28:09.962160+0800 MultithreadingDemo[59365:5508927] pthread_mutex递归锁---1
2020-01-02 10:28:09.962310+0800 MultithreadingDemo[59365:5508927] pthread_mutex递归锁---2
2020-01-02 10:28:09.962407+0800 MultithreadingDemo[59365:5508927] pthread_mutex递归锁---3
2020-01-02 10:28:09.962471+0800 MultithreadingDemo[59365:5508927] pthread_mutex递归锁---4
2020-01-02 10:28:09.962526+0800 MultithreadingDemo[59365:5508927] pthread_mutex递归锁---5

可见换成递归锁后打印结果和我们预期一样。那递归锁为什么可以重复加锁而不会造成死锁呢?我这里提供一个自己实现递归锁的思路:前面介绍自旋锁时,我们可以用一个BOOL类型的变量来记录锁加锁、解锁状态,在递归锁里面我们还用BOOL类型的变量的话显然就行不通了。我们可以用用一个int类型的变量lockCount(加锁次数)来记录锁的状态,每加一次锁lockCount就+1,每次解锁lockCount就-1,当lockCount为0时就表示是未加锁的状态。当然,我们还要保证只有同一个线程才能重复加锁,而其他线程来访问的话还是需要等待锁的释放,所以我们还必须将当前持有锁的线程给记录下来。


2.3.3 pthread_mutex---条件锁

当某线程获取了锁对象,但因为某些条件没有满足,需要在这个条件上等待,直到条件满足才能够往下继续执行时,就需要用到条件锁

举个例子,游戏中有产生敌人和杀死敌人2个方法是用同一把锁进行加锁的,杀死敌人有个前提条件就是必须有敌人存,如果在杀死敌人的线程获取到锁后发现敌人不存在,那这个线程就要等待,等有新的敌人产生了再进行杀死操作。比如下面代码中的案例,程序先调用了killEnemy方法,后调用createEnemy方法,此时就会出现条件等待。(实际开发中可能并不会这样设计代码逻辑,这里只是为了讲解条件锁的运行流程才这样设计。)

- (void)pthreadMutexConditionLockTest{
    [self initPthreadConditionLock]; // 初始化锁、条件和一些相关数据
    
    // 创建一个线程调用killEnemy方法(此时还没有敌人,所以会进入等待状态)
    dispatch_queue_t queue = dispatch_queue_create("lock", DISPATCH_QUEUE_CONCURRENT);
    dispatch_async(queue, ^{
        NSLog(@"killEnemy开始--%@<##>",[NSThread currentThread]);
        [self killEnemy];
        NSLog(@"killEnemy结束--%@<##>",[NSThread currentThread]);
    });
    
    // 2秒后再创建一个线程调用createEnemy方法来产生敌人
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), queue, ^{
        NSLog(@"createEnemy开始--%@<##>",[NSThread currentThread]);
        [self createEnemy];
        NSLog(@"createEnemy结束--%@<##>",[NSThread currentThread]);
    });
}

// 初始化锁、条件和一些相关数据(确保只初始化一次)
- (void)initPthreadConditionLock{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        pthread_mutex_init(&_pthreadMutexConditionLock, NULL); // 第二个参数传NULL时是一个常规锁
        pthread_cond_init(&_pthreadCondition, NULL); // 初始化条件,第二个参数一般就传NULL
    });
    
    if (!_enemyArr) {
        _enemyArr = [NSMutableArray array];
    }else{
        [_enemyArr removeAllObjects];
    }
}

// 杀死敌人
- (void)killEnemy{
    // 加锁
    pthread_mutex_lock(&_pthreadMutexConditionLock);
    
    if (_enemyArr.count == 0) {
        NSLog(@"还没有敌人,进入等待状态");
        pthread_cond_wait(&_pthreadCondition, &_pthreadMutexConditionLock); // 等待将锁和条件传进去
    }
    
    [_enemyArr removeLastObject];
    NSLog(@"杀死了敌人");
    
    // 解锁
    pthread_mutex_unlock(&_pthreadMutexConditionLock);
}

// 产生敌人
- (void)createEnemy{
    // 加锁
    pthread_mutex_lock(&_pthreadMutexConditionLock);
    
    NSObject *enemyObj = [NSObject new];
    [_enemyArr addObject:enemyObj];
    
    // 发送信号唤醒一条等待条件的线程(发送信号的代码放在解锁代码的前面和后面都可以,放在前面的话就是先发信号唤醒等待的线程,等解锁后等待的线程才能获得锁;放在后面的话当前线程就会先解锁,然后发送信号唤醒等待的线程,等待的线程立马就可以获得锁。)
    pthread_cond_signal(&_pthreadCondition);
//    pthread_cond_broadcast(&_pthreadCondition); // 发送广播唤醒所有等待条件的线程
    
    if(_enemyArr.count == 1) NSLog(@"敌人数从0变为1,唤醒等待中的线程。");
    
    // 解锁
    pthread_mutex_unlock(&_pthreadMutexConditionLock);
}

****************打印结果****************
2020-01-02 12:10:21.386852+0800 MultithreadingDemo[59656:5546517] killEnemy开始--<NSThread: 0x60000026ac00>{number = 3, name = (null)}<##>
2020-01-02 12:10:21.386984+0800 MultithreadingDemo[59656:5546517] 还没有敌人,进入等待状态
2020-01-02 12:10:23.386917+0800 MultithreadingDemo[59656:5546516] createEnemy开始--<NSThread: 0x6000002eb240>{number = 6, name = (null)}<##>
2020-01-02 12:10:23.387074+0800 MultithreadingDemo[59656:5546516] 敌人数从0变为1,唤醒等待中的线程。
2020-01-02 12:10:23.387217+0800 MultithreadingDemo[59656:5546516] createEnemy结束--<NSThread: 0x6000002eb240>{number = 6, name = (null)}<##>
2020-01-02 12:10:23.387219+0800 MultithreadingDemo[59656:5546517] 杀死了敌人
2020-01-02 12:10:23.387329+0800 MultithreadingDemo[59656:5546517] killEnemy结束--<NSThread: 0x60000026ac00>{number = 3, name = (null)}<##>

下面我们来讲解一下上面代码的运行流程(关于一些初始化操作就不在这里说了),我们把调用killEnemy方法的线程叫killThread,把调用createEnemy的线程叫createThread线程。

  • 首先调用killEnemy方法,killThread线程获取到锁。
  • 然后判断发现现在还没有敌人,不能进行kill操作,所以killThread释放锁并进入等待状态。
  • 2秒后调用createEnemy方法,createThread发现锁是未加锁状态,所以获取到了锁并执行生成敌人的操作。
  • 生成敌人后就会给条件发送信号(同时createThread线程会释放锁)。
  • killThread线程收到条件信号后被唤醒并重新获取到锁。
  • killThread线程被唤醒后开始执行条件等待之后的代码,等杀死敌人后将锁释放。

那么实际开发中什么场合下会用到条件锁呢?

我们可以用条件锁来建立线程中的依赖关系。比如用2个线程去执行用户登录和获取用户信息这两个网络请求(用户登录成功后会返回一个token,需要用这个token去获取用户信息,也就是说获取用户信息是依赖于用户登录的),这两个请求的执行先后顺序是不确定的,所以就有可能出现执行请求用户信息操作时用户都还没有登录,那么这个时候就可以使用条件锁进行等待,等登录成功返回token后再发送信号将其唤醒。

2.4 NSLock

前面介绍的锁都是C语言实现的,在iOS开发中使用的并不是很多,我们更多使用的是OC封装的锁。NSLock就是OC对pthread_mutex常规锁的封装。使用起来比较简单,如下所示:

- (void)nsLockSaleTicket{
    // 初始化锁
    if (!_nsLock) {
        _nsLock = [[NSLock alloc] init];
    }
    
    // 加锁
    [_nsLock lock];
    
    NSInteger oldCount = self.ticketCount;
    if (oldCount > 0) {
        [NSThread sleepForTimeInterval:1.0f];
        self.ticketCount = --oldCount;
    }
    NSLog(@"NSLock剩余票数:%ld--%@",self.ticketCount,[NSThread currentThread]);
    
    // 解锁
    [_nsLock unlock];
}

NSLock还有下面两个方法:

/* 
尝试加锁
如果获取到了锁就加锁并返回YES
如果现在锁被另外一个线程持有,那就返回NO,而且不会在这里等待锁的释放,而是会继续执行后面的代码。
*/
- (BOOL)tryLock;

/*
在某个时间之前尝试加锁
如果在这个时间之前获取到了锁就加锁并返回YES
如果到这个时间点还没获取到锁就返回NO,而且不会在这里等待锁的释放,而是会继续执行后面的代码。
*/
- (BOOL)lockBeforeDate:(NSDate *)limit;

2.5 NSRecursiveLock(递归锁)

NSRecursiveLock是一个递归锁,是OC对pthread_mutex递归锁的封装。NSRecursiveLock的API调用方法和NSLock是一样的。

- (void)nsRecursiveLockTest{
    if (!_nsRecursiveLock) { // 初始化锁
        _nsRecursiveLock = [[NSRecursiveLock alloc] init];
    }
    
    // 加锁
    [_nsRecursiveLock lock];
    
     // 加锁代码为递归调用
    static NSInteger i = 5;
    NSInteger temp = i--;
    if (temp > 0) {
        [self nsRecursiveLockTest];
    }
    NSLog(@"NSRecursiveLock 递归锁---%ld",temp);
     
     // 解锁
     [_nsRecursiveLock unlock];
}

2.6 NSCondition(条件锁)

NSCondition是一个条件锁,是OC对pthread_mutex条件锁的封装。NSCondition的API和使用方式和pthread_mutex条件锁是一样的,将前面条件锁的案例换成NSCondition,代码如下:

- (void)nsConditionTest{
    // 初始化锁
    if (!_nsCondition) {
        _nsCondition = [[NSCondition alloc] init];
        _enemyArr = [NSMutableArray array];
    }
    
    // 创建一个线程调用killEnemy1方法(此时还没有敌人,所以会进入等待状态)
    dispatch_queue_t queue = dispatch_queue_create("NSCondition", DISPATCH_QUEUE_CONCURRENT);
    dispatch_async(queue, ^{
        NSLog(@"killEnemy1开始--%@<##>",[NSThread currentThread]);
        [self killEnemy1];
        NSLog(@"killEnemy1结束--%@<##>",[NSThread currentThread]);
    });
    
    // 2秒后再创建一个线程调用createEnemy1方法来产生敌人
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), queue, ^{
        NSLog(@"createEnemy1开始--%@<##>",[NSThread currentThread]);
        [self createEnemy1];
        NSLog(@"createEnemy1结束--%@<##>",[NSThread currentThread]);
    });
}

// 杀死敌人
- (void)killEnemy1{
    // 加锁
    [_nsCondition lock];
    
    if (_enemyArr.count == 0) {
        NSLog(@"还没有敌人,进入等待状态");
        [_nsCondition wait]; // 等待
    }
    
    [_enemyArr removeLastObject];
    NSLog(@"杀死了敌人");
    
    // 解锁
    [_nsCondition unlock];
}

// 产生敌人
- (void)createEnemy1{
    // 加锁
    [_nsCondition lock];
    
    NSObject *enemyObj = [NSObject new];
    [_enemyArr addObject:enemyObj];
    
    // 发送信号唤醒一条等待条件的线程
    [_nsCondition signal];
//    [_nsCondition broadcast]; // 发送广播唤醒所有等待条件的线程
    
    if(_enemyArr.count == 1) NSLog(@"敌人数从0变为1,唤醒等待中的线程。");
    
    // 解锁
    [_nsCondition unlock];
}

2.7 NSConditionLock(条件锁)

NSConditionLock也是一个条件锁,它是对NSCondition的进一步封装,与NSCondition不同的是NSConditionLock可以设置条件值。我们可以通过设置不同的条件值来建立不同线程之间的依赖关系。比如下面案例中获取用户信息操作要依赖用户登陆操作,其加锁流程如下:

  • 首先初始化锁时设置的条件值是1(条件值不设置的话默认是0);
  • 然后开启一个线程执行获取用户信息操作,获取用信息的线程执行[_nsConditionLock lockWhenCondition:2],这句代码的意思是当条件值是2时就获取锁进行加锁,由于初始化的条件值是1,所以加锁失败,该线程进入等待状态,等条件值变为2时就会被唤醒。
  • 1秒钟后又创建了一个线程执行用户登陆操作,登陆操作的线程执行[_nsConditionLock lockWhenCondition:1],而此时条件值正好是1,所以加锁成功,开始登陆,登陆成功后执行[_nsConditionLock unlockWithCondition:2],意思是当前线程解锁,并将条件设置为2。
  • 条件值变为2后获取用信息的线程从等待状态变被唤醒并加锁,开始获取用户信息,获取用户信息结束后解锁,整个流程就结束了。
- (void)nsConditionLockTest{
    // 用条件值初始化锁(也可以直接[[NSConditionLock alloc] init]来初始化,这样初始化的条件值是0)
    if(!_nsConditionLock){
        _nsConditionLock = [[NSConditionLock alloc] initWithCondition:1];
    }

    // 开启一个线程执行获取用户信息操作
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    dispatch_async(queue, ^{
        NSLog(@"开始获取用户信息");
        [self getUserInfoTest];
        NSLog(@"获取用户信息结束");
    });
    
    // 1秒钟后开启另一个线程执行登陆操作
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0f * NSEC_PER_SEC)), queue, ^{
        NSLog(@"开始登陆");
        [self loginTest];
        NSLog(@"结束登陆");
    });
}

// 模拟登陆
- (void)loginTest{
    [_nsConditionLock lockWhenCondition:1];
    
    [NSThread sleepForTimeInterval:1.0f];
    NSLog(@"登陆成功");
    
    [_nsConditionLock unlockWithCondition:2];
}

// 模拟获取用户信息
- (void)getUserInfoTest{
    [_nsConditionLock lockWhenCondition:2];
    
    [NSThread sleepForTimeInterval:1.0f];
    NSLog(@"获取用户信息成功");
    
    [_nsConditionLock unlock];
}

****************打印结果****************
2020-01-02 22:04:39.682835+0800 MultithreadingDemo[90902:8539476] 开始获取用户信息
2020-01-02 22:04:40.682996+0800 MultithreadingDemo[90902:8539474] 开始登陆
2020-01-02 22:04:41.684167+0800 MultithreadingDemo[90902:8539474] 登陆成功
2020-01-02 22:04:41.684664+0800 MultithreadingDemo[90902:8539474] 结束登陆
2020-01-02 22:04:42.689556+0800 MultithreadingDemo[90902:8539476] 获取用户信息成功
2020-01-02 22:04:42.689911+0800 MultithreadingDemo[90902:8539476] 获取用户信息结束

上面我们都是通过[_nsConditionLock lockWhenCondition:1]来加锁,这种方式要等到条件值满足要求时才能加锁成功。我们也可以直接通过[_nsConditionLock lock]这种方式来加锁,这种方式不管条件值是多少都可以加锁成功。

2.8 串行队列实现线程同步

线程同步的本质就是保证同一时间只有一个线程访问要加锁的代码块,那么我们可以将要加锁的代码块当做成一个任务同步添加到GCD的串行队列中,串行队列可以保证一次只有一个任务在执行,前一个任务执行完了才能执行下一个任务。代码如下:

- (void)serialQueueSaleTicket{
    if (!_serialQueue) {
        _serialQueue = dispatch_queue_create("serialQueue", DISPATCH_QUEUE_SERIAL);
    }
    
    dispatch_sync(_serialQueue, ^{ // 要加锁的代码块当成任务同步添加到队列中
        NSInteger oldCount = self.ticketCount;
        if (oldCount > 0) {
            [NSThread sleepForTimeInterval:1.0f];
            self.ticketCount = --oldCount;
        }
        NSLog(@"串行队列--剩余票数:%ld--%@",self.ticketCount,[NSThread currentThread]);
    });
}


****************打印结果****************
2020-01-02 23:14:34.094617+0800 MultithreadingDemo[1146:25021] 串行队列--剩余票数:9--<NSThread: 0x6000021f5700>{number = 6, name = 窗口1}
2020-01-02 23:14:35.096506+0800 MultithreadingDemo[1146:25022] 串行队列--剩余票数:8--<NSThread: 0x6000021f5800>{number = 7, name = 窗口2}
2020-01-02 23:14:36.099239+0800 MultithreadingDemo[1146:25021] 串行队列--剩余票数:7--<NSThread: 0x6000021f5700>{number = 6, name = 窗口1}
2020-01-02 23:14:37.099940+0800 MultithreadingDemo[1146:25022] 串行队列--剩余票数:6--<NSThread: 0x6000021f5800>{number = 7, name = 窗口2}
2020-01-02 23:14:38.103534+0800 MultithreadingDemo[1146:25021] 串行队列--剩余票数:5--<NSThread: 0x6000021f5700>{number = 6, name = 窗口1}
2020-01-02 23:14:39.105606+0800 MultithreadingDemo[1146:25022] 串行队列--剩余票数:4--<NSThread: 0x6000021f5800>{number = 7, name = 窗口2}
2020-01-02 23:14:40.107929+0800 MultithreadingDemo[1146:25021] 串行队列--剩余票数:3--<NSThread: 0x6000021f5700>{number = 6, name = 窗口1}
2020-01-02 23:14:41.111818+0800 MultithreadingDemo[1146:25022] 串行队列--剩余票数:2--<NSThread: 0x6000021f5800>{number = 7, name = 窗口2}
2020-01-02 23:14:42.117835+0800 MultithreadingDemo[1146:25021] 串行队列--剩余票数:1--<NSThread: 0x6000021f5700>{number = 6, name = 窗口1}
2020-01-02 23:14:43.119191+0800 MultithreadingDemo[1146:25022] 串行队列--剩余票数:0--<NSThread: 0x6000021f5800>{number = 7, name = 窗口2}

2.9 dispatch_semaphore_t(信号量)

dispatch_semaphore_t是GCD中用于控制最大并发数的,其初始化时设置的信号量值就是最大并发数,当信号量值初始化为1时表示最大并发数为1,也就达到了同一时间只有一个线程访问要加锁代码块的目的,从而实现代码同步。

// dispatch_semaphore_wait()和dispatch_semaphore_signal()之间的代码就是要加锁的代码
- (void)dispatchSemaphoreSaleTicket{
    if (!_semaphore) { // 初始化信号量为1,表最大并发数为1
        _semaphore = dispatch_semaphore_create(1);
    }
    
    // 如果信号量>0,则信号量-1并执行后面代码
    // 如果信号量<=0,则线程进入等待状态(线程休眠),第二个参数是等待时间,等信号量大于0或者等待超时时开始执行后续代码(DISPATCH_TIME_FOREVER表示永不超时)
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    
    NSInteger oldCount = self.ticketCount;
    if (oldCount > 0) {
        [NSThread sleepForTimeInterval:1.0f];
        self.ticketCount = --oldCount;
    }
    NSLog(@"信号量--剩余票数:%ld--%@",self.ticketCount,[NSThread currentThread]);
    
    // 执行dispatch_semaphore_signal()函数信号量+1
    dispatch_semaphore_signal(_semaphore);
}

****************打印结果****************
2020-01-02 23:33:14.201100+0800 MultithreadingDemo[1454:34572] 信号量--剩余票数:9--<NSThread: 0x600002064500>{number = 7, name = 窗口1}
2020-01-02 23:33:15.202743+0800 MultithreadingDemo[1454:34573] 信号量--剩余票数:8--<NSThread: 0x600002064600>{number = 8, name = 窗口2}
2020-01-02 23:33:16.208335+0800 MultithreadingDemo[1454:34572] 信号量--剩余票数:7--<NSThread: 0x600002064500>{number = 7, name = 窗口1}
2020-01-02 23:33:17.213433+0800 MultithreadingDemo[1454:34573] 信号量--剩余票数:6--<NSThread: 0x600002064600>{number = 8, name = 窗口2}
2020-01-02 23:33:18.218910+0800 MultithreadingDemo[1454:34572] 信号量--剩余票数:5--<NSThread: 0x600002064500>{number = 7, name = 窗口1}
2020-01-02 23:33:19.224498+0800 MultithreadingDemo[1454:34573] 信号量--剩余票数:4--<NSThread: 0x600002064600>{number = 8, name = 窗口2}
2020-01-02 23:33:20.230172+0800 MultithreadingDemo[1454:34572] 信号量--剩余票数:3--<NSThread: 0x600002064500>{number = 7, name = 窗口1}
2020-01-02 23:33:21.235774+0800 MultithreadingDemo[1454:34573] 信号量--剩余票数:2--<NSThread: 0x600002064600>{number = 8, name = 窗口2}
2020-01-02 23:33:22.237456+0800 MultithreadingDemo[1454:34572] 信号量--剩余票数:1--<NSThread: 0x600002064500>{number = 7, name = 窗口1}
2020-01-02 23:33:23.238408+0800 MultithreadingDemo[1454:34573] 信号量--剩余票数:0--<NSThread: 0x600002064600>{number = 8, name = 窗口2}

2.10 @synchronized

@synchronized是使用起来最简单的锁,写法很简单:@synchronized (obj) {}。它的底层是对pthread_mutex递归锁的封装,其内部会根据小括号传入的对象生成对应的递归锁,然后进行加锁解锁操作。不过这个锁虽然写起来方便,但是性能比较差。

- (void)synchronizedTest{
    @synchronized (self) {
        // 加锁代码为递归调用
           static NSInteger i = 5;
           NSInteger temp = i--;
           if (temp > 0) {
               [self nsRecursiveLockTest];
           }
           NSLog(@"NSRecursiveLock 递归锁---%ld",temp);
    }
}

****************打印结果****************
2020-01-02 23:55:14.626155+0800 MultithreadingDemo[1717:43609] NSRecursiveLock 递归锁---0
2020-01-02 23:55:14.626455+0800 MultithreadingDemo[1717:43609] NSRecursiveLock 递归锁---1
2020-01-02 23:55:14.626658+0800 MultithreadingDemo[1717:43609] NSRecursiveLock 递归锁---2
2020-01-02 23:55:14.626836+0800 MultithreadingDemo[1717:43609] NSRecursiveLock 递归锁---3
2020-01-02 23:55:14.627004+0800 MultithreadingDemo[1717:43609] NSRecursiveLock 递归锁---4
2020-01-02 23:55:14.627160+0800 MultithreadingDemo[1717:43609] NSRecursiveLock 递归锁---5
2020-01-02 23:55:14.627601+0800 MultithreadingDemo[1717:43609] NSRecursiveLock 递归锁---5

2.11 iOS线程同步方案小结

iOS线程同步方案性能比较:

性能从高到低排序:

  • os_unfair_lock
  • OSSpinLock
  • dispatch_semaphore
  • pthread_mutex
  • dispatch_queue(DISPATCH_QUEUE_SERIAL)
  • NSLock
  • NSCondition
  • pthread_mutex(recursive)
  • NSRecursiveLock
  • NSConditionLock
  • @synchronized

什么情况使用自旋锁比较划算?

  • 预计线程等待锁的时间很短
  • 加锁的代码(临界区)经常被调用,但竞争情况很少发生
  • CPU资源不紧张
  • 多核处理器

什么情况使用互斥锁比较划算?

  • 预计线程等待锁的时间较长
  • 单核处理器
  • 临界区有IO操作(文件操作)
  • 临界区代码复杂或者循环量大
  • 临界区竞争非常激烈

3. iOS中的读写安全方案

当多个线程同时访问一个资源时容易造成数据错乱,其根本原因就在于写的操作上,因为在没有进行写操作时,同一个数据无论同时读多少次得到的结果都是一样的。而且读操作和写操作是不能同时进行的。所以为了保证读写安全,必须满足以下3个条件:

  • 同一时间,只能有1个线程进行写的操作
  • 同一时间,允许有多个线程进行读的操作
  • 同一时间,不允许既有写的操作,又有读的操作

上面的场景就是典型的“多读单写”,经常用于文件等数据的读写操作,iOS中的实现方案有pthread_rwlock(读写锁)和GCD的dispatch_barrier_async(异步栅栏函数)。

3.1 pthread_rwlock(读写锁)

pthread_rwlock需要引入头文件#import <pthread.h>,等待锁的线程会进入休眠。下面是相关的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);

下面我们来看下实际代码演示:

- (void)pthreadRwlockTest{

   // 初始化锁
   pthread_rwlock_init(&_pthreadRwlock, NULL);
   
   dispatch_queue_t queue = dispatch_queue_create("rwlock", DISPATCH_QUEUE_CONCURRENT);
   
   for (NSInteger i = 0; i < 2; i++) {
       dispatch_async(queue, ^{
           [self read];
           [self read];
           [self write];
           [self write];
           [self read];
           [self read];
       });
   }
}

// 读操作
- (void)read{
   // 读加锁
   pthread_rwlock_rdlock(&_pthreadRwlock);
   
   [NSThread sleepForTimeInterval:1.0f];
   NSLog(@"读操作");
   
   // 解锁
   pthread_rwlock_unlock(&_pthreadRwlock);
}

// 写操作
- (void)write{
   // 写加锁
   pthread_rwlock_wrlock(&_pthreadRwlock);
   
   [NSThread sleepForTimeInterval:1.0f];
   NSLog(@"写操作");
   
   // 解锁
   pthread_rwlock_unlock(&_pthreadRwlock);
}

// ****************打印结果****************
2020-01-03 09:33:53.220426+0800 MultithreadingDemo[61575:5891181] 读操作
2020-01-03 09:33:53.220490+0800 MultithreadingDemo[61575:5891182] 读操作
2020-01-03 09:33:54.221545+0800 MultithreadingDemo[61575:5891182] 读操作
2020-01-03 09:33:54.221570+0800 MultithreadingDemo[61575:5891181] 读操作
2020-01-03 09:33:55.225951+0800 MultithreadingDemo[61575:5891182] 写操作
2020-01-03 09:33:56.231264+0800 MultithreadingDemo[61575:5891181] 写操作
2020-01-03 09:33:57.231891+0800 MultithreadingDemo[61575:5891182] 写操作
2020-01-03 09:33:58.233379+0800 MultithreadingDemo[61575:5891181] 写操作
2020-01-03 09:33:59.235486+0800 MultithreadingDemo[61575:5891181] 读操作
2020-01-03 09:33:59.235522+0800 MultithreadingDemo[61575:5891182] 读操作
2020-01-03 09:34:00.237975+0800 MultithreadingDemo[61575:5891181] 读操作
2020-01-03 09:34:00.237974+0800 MultithreadingDemo[61575:5891182] 读操作

3.2 dispatch_barrier_async(异步栅栏函数)

GCD中dispatch_barrier_async的特点就是它不会阻塞当前线程,但是它会阻塞同一个派发队列中在它之后的任务的执行。因此我们可以将写的操作放在异步栅栏函数中,读操作放在普通的GCD异步函数中。要注意的是,要写栅栏函数生效,必须相关任务都放在同一个派发队列中,并且要是自己创建的并发队列。代码如下所示:

- (void)dispatchBarrierAsync{
   _readWriteQueue = dispatch_queue_create("readWriteQueue", DISPATCH_QUEUE_CONCURRENT);
   
   for (NSInteger i = 0; i < 2; i++) {
       dispatch_async(_readWriteQueue, ^{
           [self read1];
           [self read1];
           [self write1];
           [self write1];
           [self read1];
           [self read1];
       });
   }
}

// 读操作
- (void)read1{
   dispatch_async(_readWriteQueue, ^{
       [NSThread sleepForTimeInterval:1.0f];
       NSLog(@"读操作");
   });
}

// 写操作
- (void)write1{
   dispatch_barrier_async(_readWriteQueue, ^{
       [NSThread sleepForTimeInterval:1.0f];
       NSLog(@"写操作");
   });
}

// ****************打印结果****************
2020-01-03 09:52:23.311609+0800 MultithreadingDemo[61639:5898905] 读操作
2020-01-03 09:52:23.311593+0800 MultithreadingDemo[61639:5898906] 读操作
2020-01-03 09:52:24.316886+0800 MultithreadingDemo[61639:5898905] 写操作
2020-01-03 09:52:25.319288+0800 MultithreadingDemo[61639:5898905] 写操作
2020-01-03 09:52:26.321448+0800 MultithreadingDemo[61639:5898905] 读操作
2020-01-03 09:52:26.321448+0800 MultithreadingDemo[61639:5898908] 读操作
2020-01-03 09:52:26.321448+0800 MultithreadingDemo[61639:5898906] 读操作
2020-01-03 09:52:26.321452+0800 MultithreadingDemo[61639:5898907] 读操作
2020-01-03 09:52:27.325920+0800 MultithreadingDemo[61639:5898907] 写操作
2020-01-03 09:52:28.330862+0800 MultithreadingDemo[61639:5898907] 写操作
2020-01-03 09:52:29.333543+0800 MultithreadingDemo[61639:5898907] 读操作
2020-01-03 09:52:29.333543+0800 MultithreadingDemo[61639:5898908] 读操作