面试遇到多线程的第二天-安全隐患(锁)

621 阅读9分钟

接着上篇文章中,对死锁情况的一些分享,本文再来说说多线程使用中的安全隐患以及解决方案。

多线程安全隐患

使用多线程开发时会有哪些安全隐患呢?也许你开发中曾遇到过,比如多个线程同时访问同一个变量,同一处内存,对他们同时进行读写操作,就会出现不安全的情况,通俗的说就是你访问到的变量的值或者内存中的数据,可能并不是你想要的,他再被多个线程访问的时候,数据出现了错乱。

比如常见的多个窗口卖票问题,是比较经典的用来模拟多线程同时访问的例子

- (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

这里有两个点强调一下,也是后面分析其他方案都需要注意的:

  1. 存钱取钱的例子,和卖票的例子不同点在于多线程中是调用了两个不同的方法,而存取也是不能同时进行的,所以存取需要用同一把锁
  2. 方法内部如果lock是个局部变量, 每次进这个方法都会重新初始化一个锁,会导致加锁失败,也必须用同一把锁加锁,所以可以用成员变量或者static变量,或者用dispatch_once创建锁 都可以

不安全

为什么说OSSpinLock现在已经不再安全了,其实还是这个锁的性质决定的。自旋锁,等待的线程都处于忙等,可能会出现优先级反转的问题。

比如优先级低的线程先加锁, 优先级高的线程再访问进入忙等, CPU一直分配资源给优先级高的线程, 可能导致没有资源分给优先级低的线程 ,导致优先级低的线程一直不能解锁 ,高的一直等待还占着CPU资源。

所以就有了os_unfair_lock,用于取代OSSpinLock。

os_unfair_lock

os_unfair_lock从iOS10之后开始支持,用于取代不安全的OSSpinLock,之所以能取代OSSpinLock,他是如何解决安全性的呢?

这里要知道线程阻塞的两种方案

  1. 线程睡眠 不占用CPU
  2. 忙等 一直占用这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

总结一下:

  1. os_unfair_lock虽然性能高,但是从iOS10才开始支持,有版本限制;
  2. 推荐使用信号量和mutex;
  3. 持续封装的,多多少少都会影响一些性能;
  4. 能不用就不用递归锁;

自旋锁和互斥锁

了解了自旋锁和互斥锁的工作原理,我们再分析一下不同场景下如何抉择用哪一种锁

适用于自旋锁的情况:

  • 线程等待时间很短,就直接忙等了,毕竟线程休眠再唤醒也是一个耗性能的过程
  • 被加锁的代码被频繁调用,但是竞争情况很少
  • 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);