iOS底层原理深度体会Mac·iOS开发生活和工作

如何保证iOS的多线程安全

2019-08-24  本文已影响9人  RUNNING_NIUER

什么会给多线程的安全造成隐患?

有了多线程技术支持,我们可以并发的进行多个任务,因此同一块资源就有可能在多个线程中同时被访问(读/写)。这个现象叫作资源共享,比如多个线程同时访问了同一个对象,同一个变量或同一个文件,这样就有可能引发数据错乱何数据安全问题。

经典问题一——存钱取钱

存钱取钱问题 下面通过代码展示一下该问题
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [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 {
    NSInteger oldMoney = self.money;
    sleep(.2);//模拟任务时长,便于问题显现
    oldMoney += 50;
    self.money = oldMoney;
    NSLog(@"存了50元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
}

-(void)drawMoney {
    NSInteger oldMoney = self.money;
    sleep(.2);//模拟任务时长,便于问题显现
    oldMoney -= 20;
    self.money = oldMoney;
    NSLog(@"取了20元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
}

我们在moneyTest方法中,以多线程方式分别进行了10次的存钱取钱操作,每次存50,每次取20,存款初值为100,目标余额应该为100+ (50*10) - (20*10) = 400,运行结果如下

多线程存钱取钱问题
可以看出最后的余额数不对。

经典问题二——卖票问题

卖票问题 下面通过代码展示一下该问题
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self sellTicketTest];
}

//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<5; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<5; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<5; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
}

sellTicketTest里面,起始票数30,通过3条线程同时卖票,每条线程卖10张,最有应该全部卖完才对,运行程序,结果如下

多线程卖票问题
打印结果看出最后的剩余票数发生了错误。 上述两个经典问题都是由于多条线程对同一资源进行了读写操作而导致的。用一个大家都熟悉的图片来表示就是

针对这个问题的解决方案:使用线程同步技术(同步,就是协同步调,按预定的先后次序进行)。常见的线程同步技术就是:加锁
先用下图概括一下

线程加锁

线程同步(加锁)方案

iOS中的线程同步方案有如下几种:

下面我们来依次体验一下。

(一)OSSpinLock
OSSpinLock叫作”自旋锁“,需要导入头文件#import <libkern/OSAtomic.h>。它有如下API

下面来看一下代码中如何使用它。以下代码承接上面的卖票案例进行加锁操作

@interface ViewController ()
@property (nonatomic, assign) NSInteger ticketsCount;
@end

*******************************************************

@implementation ViewController
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self sellTicketTest];
//    [self moneyTest];
}
//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    //初始化锁
    OSSpinLock lock = OS_SPINLOCK_INIT;
    //加锁🔒🔒🔒🔒
    OSSpinLockLock(&lock);
    
    
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //解锁🔓🔓🔓🔓
    OSSpinLockUnlock(&lock);
}
@end

运行后结果

结果看没成功,怎么回事呢?这里补充一下加锁的原理:一张图搞定 加锁原理

所以根据上图的原理,我们应该使用同一个锁对象来给某个操作(代码段)加锁。所以将上面的锁写成一个全局性质的属性即可,代码如下

@interface ViewController ()
@property (nonatomic, assign) NSInteger ticketsCount;
@property (nonatomic, assign) OSSpinLock lock;
@end
*******************************************************
@implementation ViewController

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self sellTicketTest];
}

//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
    //初始化锁
    self.lock = OS_SPINLOCK_INIT;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    
    //加锁🔒🔒🔒🔒
    OSSpinLockLock(&_lock);
    
    
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //解锁🔓🔓🔓🔓
    OSSpinLockUnlock(&_lock);
}
@end
这样最后的结果就没问题了 image.png

卖票的问题,我们针对的是同一个操作来处理的,而存钱取钱的问题,涉及到了两个操作(存钱操作和取钱操作),来看看该怎么处理。首先要明确问题,加锁机制是为了解决从多条线程同时访问共享资源所所产生的数据问题,无论这些线程里面执行的是相同的操作(比如卖票),还是不同的操作(存钱取钱),所以可以认为跟操作是没关系的,问题的本质是需要确定清楚,哪些操作是不能同时进行的,然后对这些操作使用相同的锁对象进行加锁。

因此,由于存钱操作和取钱操作也是不能同时进行的(就是说不能同时两条线程存钱,不能同时两条线程取钱,也不能同时两条线程分别存钱和取钱),因此我们需要对存钱操作和取钱操作使用相同的锁对象进行加锁。
代码如下

@interface ViewController ()
@property (nonatomic, assign) NSInteger money;
@property (nonatomic, assign) OSSpinLock lock;
@end

****************************************

@implementation ViewController
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
//    [self sellTicketTest];
    [self moneyTest];
}
//存钱取钱问题
-(void)moneyTest {
    self.money = 100;
    //初始化锁
    self.lock = OS_SPINLOCK_INIT;
    
    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(&_lock);
    
    NSInteger oldMoney = self.money;
    sleep(.2);//模拟任务时长,便于问题显现
    oldMoney += 50;
    self.money = oldMoney;
    NSLog(@"存了50元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
    //解锁🔓🔓🔓🔓
    OSSpinLockUnlock(&_lock);
    
}

-(void)drawMoney {
    //加锁🔒🔒🔒🔒
    OSSpinLockLock(&_lock);
    
    NSInteger oldMoney = self.money;
    sleep(.2);//模拟任务时长,便于问题显现
    oldMoney -= 20;
    self.money = oldMoney;
    NSLog(@"取了20元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
    //解锁🔓🔓🔓🔓
    OSSpinLockUnlock(&_lock);
}

@end
最后得到了正确的结果
自旋锁原理

自旋锁的原理是当加锁失败的时候,让线程处于忙等的状态(busy-wait),以此让线程停留在临界区(需要加锁的代码段)之外,一旦加锁成功,线程便可以进入临界区进行对共享资源操作。

让线程阻塞有两种方法:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self sellTicketTest];
}

-(void)sellTicketTest {
    self.ticketsCount = 30;
    //初始化锁
    self.lock = OS_SPINLOCK_INIT;
    //开启10条线程进行卖票操作
    for (int i = 0; i<10; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(sellTicket) object:nil] start];
    }
}


-(void)sellTicket {
    
    //加锁🔒🔒🔒🔒
    OSSpinLockLock(&_lock);
    
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(600);//⚠️⚠️⚠️任务时长模拟为10分钟,方便汇编调试
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //解锁🔓🔓🔓🔓
    OSSpinLockUnlock(&_lock);
}

我们在卖票方法的加锁代码处加一个断点,运行程序,第一条线程走到断点处,可以加锁成功

跳过断点,该线程继续执行下面的代码,同时会有第二条来到加锁代码断点处 然后从这里开始,我们进入汇编码界面,开始追踪底层的函数调用栈。我们过掉第一个断点,马上就会有第二条线程来到断点处,此时该线程肯定会被阻塞。Xcode里面在断点调试界面下,可以通过Debug -> Debug Workflow -> Always Show Disassembly切换到汇编界面。我们可以在控制台通过命令si执行一句汇编代码,入当前汇编指令是调用函数的话,便会进入到该函数的汇编界面。好下面开始调试 解读一下上面汇编追踪过程的函数调用栈:
sellTicket
-> OSSpinLockLock
-> _OSSpinLockLockSlow
_OSSpinLockLockSlow里面,你会看到程序会在cmpl指令和jne指令之间循环滚动,如果你不了解arm64的汇编指令,那么这里可以先告诉你,cmpl是将两个值进行比较,jne是指如果不相等就跳转(jump if not equal),程序在这两者之间不断循环,相信只要有一定编程敏感度的话,应该能猜出这个东西了吧?对,这就是一个while循环,图中框起来的几句汇编码,是一个典型的while循环汇编实现。这样,我们就证明了,自旋锁的本质,就是一个while循环。
自旋锁为什么被抛弃

苹果已经建议开发者停止使用自旋锁,因为在线程优先级的作用下,会产生【优先级反转】,使得自旋锁卡住,因此它不再安全了。

我们知道,计算机的CPU在同一时间,只能处理一条线程,对于单CPU来说,线程的并发,实际上是一种假象,是系统让CPU以很小的时间间隔在线程之间来回切换,所以看上去多条线程好像是在同时进行的。到了多核CUP时代,确实是可以实现真正的线程并发,但是CUP核心数毕竟是有限的,而程序内部的线程数量通常肯定是远大于CPU的数量的,因此,很多情况下我们面对的还是单CPU处理多线程的情况。基于这种场景,需要了解一个概念叫作线程优先级,CPU会将尽可能多的时间(资源)分配给优先级高的线程,知道了这一点,下面通过图来展示一下所谓的优先级反转问题

自旋锁优先级反转问题

自旋锁的while循环本质,使得线程并没有停下来,一般情况下,一条线程等待锁时间不会太长,选用自旋做来阻塞线程所消耗的CPU资源,要小于线程的休眠和唤醒所带来的CPU资源开销,因此自旋锁是一种效率很高的加锁机制,但是优先级反转问题使得自旋锁不再安全,锁的最终目的是安全而不是效率,因此苹果放弃了自旋锁。

另外为什么RunLoop要选择真正的线程休眠呢?因为对于App来说,可能处于长时间的搁置状态,而没有任何用户行为发生,不需要CPU管,对于这种场景,当然是让线程休眠更为节约性能。好了,自旋锁的今生前世就介绍到这里,虽然它已成历史,但是了解一下肯定是更好的。

(二)os_unfair_lock
苹果建议开发这,从iOS10.0之后,就应该用os_unfair_lock来取代OSSpinLock,接下来我们就来了解一下这中锁。

要使用os_unfair_lock,需要导入头文件#import <os/lock.h>,它有如下API

它的使用和OSSpinLock的方法一样,此处不赘述,苹果味了解决OSSpinLock的优先级反转问题,在os_unfair_lock中摈弃了忙等方式,用使线程真正休眠的方式,来阻塞线程,也就从根本上解决了之前的问题。

(三)pthread_mutex
pthread_mutex来自与pthread,是一个跨平台的解决方案。mutex意为互斥锁,等待锁的线程会处于休眠状态。与之相反的是我们之前介绍的第一种自旋锁,它是不休眠的。它有如下API
初始化锁的属性
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);

先上一份完整的代码案例,针对卖票问题

#import <pthread.h>

@interface ViewController ()
@property (nonatomic, assign) NSInteger ticketsCount;
//pthread_mutex
@property (nonatomic, assign) pthread_mutex_t ticketMutexLock;
@end

@implementation ViewController
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self sellTicketTest];
}

//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
  
//初始化属性
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_NORMAL);
    //初始化锁pthread_mutex
    pthread_mutex_init(&_ticketMutexLock, &attr);
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    
    //加锁🔒🔒🔒🔒
    //加pthread_mutex
    pthread_mutex_lock(&_ticketMutexLock);
    
    
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //解锁🔓🔓🔓🔓
    //开锁pthread_mutex
    pthread_mutex_unlock(&_ticketMutexLock);
}

-(void)dealloc {
    pthread_mutex_destroy(&_ticketMutexLock);
}
@end

可以看到除了初始化步骤比之前介绍的锁稍微麻烦一点,其他加锁解锁操作还是一样的。还有就是需要进行锁的释放。这里来介绍一下mutex的初始化方法

int pthread_mutex_init(pthread_mutex_t * __restrict,
        const pthread_mutexattr_t * _Nullable __restrict);

其中第一个参数就是需要进行初始化的锁对象,第二个参数是锁对象的属性。为此,我们还需要专门生成属性对象,通过 定义属性对象 --> 初始化属性对象 --> 设置属性种类这三个步骤来完成,属性的类别有以下几类

/*
 * Mutex type attributes
 */
#define PTHREAD_MUTEX_NORMAL        0
#define PTHREAD_MUTEX_ERRORCHECK    1
#define PTHREAD_MUTEX_RECURSIVE     2
#define PTHREAD_MUTEX_DEFAULT       PTHREAD_MUTEX_NORMAL

其中PTHREAD_MUTEX_DEFAULT = PTHREAD_MUTEX_NORMAL,它们都代表普通互斥锁,
PTHREAD_MUTEX_ERRORCHECK表示检查错误锁,不常用。
PTHREAD_MUTEX_RECURSIVE代表递归互斥锁,这个一会介绍。

如果我们给锁设定默认属性,那么可以用一句代码pthread_mutex_init(mutex, NULL);来搞定的锁的初始化,不用再配置属性信息。其中参数NULL表示的就是初始化一个普通的互斥锁

互斥锁的底层实现
前面我们通过查看汇编,验证了自旋锁的本质实际上就是通过一个while循环达到阻塞线程的目的。现在我们同样通过汇编,来看看互斥锁是怎么做的。
首先修改一下卖票方法如下

//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;

    //初始化属性
    pthread_mutexattr_init(&_attr);
    pthread_mutexattr_settype(&_attr, PTHREAD_MUTEX_NORMAL);
    //初始化锁
    pthread_mutex_init(&_ticketMutexLock, &_attr);
    //创建10条线程来执行卖票操作
    for (int i = 0; i<10; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(sellTicket) object:nil] start];
    }
}

-(void)sellTicket {
    //加pthread_mutex
    pthread_mutex_lock(&_ticketMutexLock);
    
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(600);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //开锁pthread_mutex
    pthread_mutex_unlock(&_ticketMutexLock);
}

-(void)dealloc {
    pthread_mutex_destroy(&_ticketMutexLock);
    pthread_mutexattr_destroy(&_attr);
}

将上面代码的卖票流程设置成600秒时长,方便我们定位问题。
首先过掉第一条线程的断点


然后来到第二条线程的断点 然后从该断点处查看汇编 总结一下上面这几个汇编跟踪图,互斥做的函数调用栈是这样的:
sellTicket
-> pthread_mutex_lock
-> _pthread_mutex_firstfit_lock_slow
-> _pthread_mutex_firstfit_lock_wait
-> __psynch_mutexwait
-> syscall
这个syscall代表系统调用,一般是调用系统级别比较内核的方法。当我们从syscall在继续执行的话,整个断点就消失了,因为该线程此时开始休眠,不在执行汇编代码了,因此也就无法追踪断点了。

我在介绍os_unfair_lock时说过,它的本质也是通过休眠来实现线程阻塞,因此可以把它归类为互斥锁,但是网上也有文章分析说它是一种自旋锁,相信经过对于自旋锁和互斥锁本质的分析,以及通过汇编来验证的方法,你应该可以自己动手实践来验证一下,到底谁说的才对。这里不在重复上面的汇编过程,总之经过汇编追踪,os_unfair_lock走到最后也是用了syscall,然后断点消失,跳出线程,通过此现象,相信你已经能判断os_unfair_lock的本质了。换个角度理解,如果它真的是自旋锁,那么【如何避免优先级反转问题】呢?强烈建议你自己试试汇编调试,实践出真知,蛮好玩的,也加深了记忆。

互斥递归锁
现在请看如下的场景

-(void)otherTest {
    NSLog(@"%s",__func__);
    [self otherTest2];    
}

-(void)otherTest2 { 
    NSLog(@"%s",__func__);
}

如果正常调用otherTest,结果大家肯定都知道,会是如下

2019-08-21 14:23:22.853271+0800 多线程安全[986:22420] -[ViewController otherTest]
2019-08-21 14:23:22.853388+0800 多线程安全[986:22420] -[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);
}

******************打印结果**********************8
2019-08-21 14:33:15.816512+0800 多线程安全[1101:29283] -[ViewController otherTest]

如果给两个方法都加上同一把锁,可以看到调用otherTest方法会导致线程卡该方法里面,只完成了打印代码的执行,就不能继续往下走了。原因也很简单,如下图

死锁的形成
要解决这个问题很简单,给两个方法加上不同的锁对象就可以解决了。

我们在开发中如果碰到需要给递归函数加锁,如下面这个

-(void)otherTest {
    pthread_mutex_lock(&_mutex);
    
    NSLog(@"%s",__func__);
    //业务逻辑

    [self otherTest];
    
    pthread_mutex_unlock(&_mutex);
}

就无法通过不同的锁对象来加锁了。只要是使用相同的锁对象,有肯定会出现死锁。针对这个问题pthread给我们提供了递归锁来解决这个问题。要想使用递归锁,只需要在初始化属性的时候,选择递归锁属性即可。其他的使用步骤跟普通互斥锁没有区别。

pthread_mutexattr_settype(&_attr, PTHREAD_MUTEX_RECURSIVE);

那么递归锁是如何避免死锁的呢?其实就是对于同一个锁对象来说,允许重复的加锁,重复的解锁,因为对于一个有出口的递归函数来说,函数的调用次数 = 函数的退出次数,因此加锁的次数pthread_mutex_lock和解锁的次数pthread_mutex_unlock是相等的,所以递归函数结束时,所有的🔒都会被解开。

但是递归锁只是针对在相同的线程里面可以重复加锁和解锁,这点要牢记。也就是除了单线程的递归函数调用,在其他场景下的重复加锁 / 解锁,递归锁时起不了重复加锁的作用的。



互斥锁条件pthread_cond_t
首先先列举一下相关API

为了解释互斥锁条件的作用,我们来设计一种场景案例:

以下是案例代码以及运行结果

@interface ViewController ()
@property (nonatomic, strong) NSMutableArray *dataArr;
//锁对象
@property (nonatomic, assign) pthread_mutex_t mutex;
//条件对象
@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(&_mutex, &_attr);
        //属性使用完,进行释放
        pthread_mutexattr_destroy(&_attr);
        //初始化条件
        pthread_cond_init(&_cond, NULL);
        //初始化数组
        self.dataArr = [NSMutableArray array];

    [self dataArrTest];
}

- (void)dataArrTest
{
    //先开启线程进行 remove,此时dataArr为空
   NSThread *removeT =  [[NSThread alloc] initWithTarget:self selector:@selector(remove) object:nil];
    [removeT setName:@"REMOVE操作线程"];
    [removeT start];
    
    sleep(.1);
    
    //然后开启线程进行 add
    NSThread *addT = [[NSThread alloc] initWithTarget:self selector:@selector(add) object:nil];
    [addT setName:@"ADD操作线程"];
    [addT start];
}

//******往数组添加元素********
-(void)add {
    
    //加锁
    pthread_mutex_lock(&_mutex);
    
    NSLog(@"[LOCK]%@加锁成功-->add开始\n",[NSThread currentThread].name);
    sleep(2);
    [self.dataArr addObject:@"test"];
    NSLog(@"add成功,dataArr内有%lu个元素,发送条件信号-------->\n",(unsigned long)self.dataArr.count);
    
    //发送条件信号
    pthread_cond_signal(&_cond);
    
    //解锁
    pthread_mutex_unlock(&_mutex);
    NSLog(@"[UNLOCK]%@解锁成功,线程结束\n",[NSThread currentThread].name);
}


//********从字典删除元素*******
-(void)remove {
    //加锁
    pthread_mutex_lock(&_mutex);
    
    
    NSLog(@"[LOCK]%@加锁成功-->remove开始\n",[NSThread currentThread].name);
    if (!self.dataArr.count) {
        //进行条件等待
        NSLog(@"dataArr没有元素,开始等待~~~~~~~~\n");
        pthread_cond_wait(&_cond, &_mutex);
        NSLog(@"-------->接受到条件更新信号,dataArr已经有了元素,继续删除操作\n");
    }
    [self.dataArr removeLastObject];
    NSLog(@"remove成功,dataArr内还剩%lu个元素\n",(unsigned long)self.dataArr.count);
    
    //解锁
    pthread_mutex_unlock(&_mutex);
    NSLog(@"[UNLOCK]%@解锁成功,线程结束\n",[NSThread currentThread].name);
}


@end

运行结果如下

2019-08-21 21:14:07.767881+0800 多线程安全[3751:204180] [LOCK]REMOVE操作线程加锁成功-->remove开始

2019-08-21 21:14:07.768075+0800 多线程安全[3751:204180] dataArr没有元素,开始等待~~~~~~~~

2019-08-21 21:14:07.768216+0800 多线程安全[3751:204181] [LOCK]ADD操作线程加锁成功-->add开始

2019-08-21 21:14:09.771757+0800 多线程安全[3751:204181] add成功,dataArr内有1个元素,发送条件信号-------->

2019-08-21 21:14:09.772048+0800 多线程安全[3751:204181] [UNLOCK]ADD操作线程解锁成功,线程结束

2019-08-21 21:14:09.772081+0800 多线程安全[3751:204180] -------->接受到条件更新信号,dataArr已经有了元素,继续删除操作

2019-08-21 21:14:09.772300+0800 多线程安全[3751:204180] remove成功,dataArr内还剩0个元素

2019-08-21 21:14:09.772496+0800 多线程安全[3751:204180] [UNLOCK]REMOVE操作线程解锁成功,线程结束
//

从案例以及运行结果分析,互斥锁的条件pthread_cond_t可以在线程加锁之后,如果条件不达标,暂停线程,等到条件符合标准,继续执行线程。这么描述还是比较抽象,请看下图

pthread_cond_t作用原理
再总结一下pthread_cond_t的作用:

(四)NSLock、NSRecursiveLock、NSCondition
上面我们了解了mutex普通锁mutex递归锁mutex条件锁,都是基于C语言的API,苹果在此基础上,进行了一层面向对象封装,为开发者供了对应的OC锁如下

由于底层就是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];

(五)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;

接下来通过案例来说明它的功能

- (instancetype)init
{
    if (self = [super init]) {
        self.conditionLock = [[NSConditionLock alloc] initWithCondition:1];
    }
    return self;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    [self otherTest];
}

- (void)otherTest
{
    [[[NSThread alloc] initWithTarget:self selector:@selector(__one) object:nil] start];
    
    [[[NSThread alloc] initWithTarget:self selector:@selector(__two) object:nil] start];
    
    [[[NSThread alloc] initWithTarget:self selector:@selector(__three) object:nil] start];
}

- (void)__one
{
    [self.conditionLock lock];
    
    NSLog(@"__one");
    sleep(1);
    
    [self.conditionLock unlockWithCondition:2];
}

- (void)__two
{
    [self.conditionLock lockWhenCondition:2];
    
    NSLog(@"__two");
    sleep(1);
    
    [self.conditionLock unlockWithCondition:3];
}

- (void)__three
{
    [self.conditionLock lockWhenCondition:3];
    
    NSLog(@"__three");
    
    [self.conditionLock unlock];
}

代码实现的效果就是__one方法先执行,再执行__two方法,最后执行__three方法。因为三个方法是在三个不同的子线程里面,所以这里精确控制了三条线程的先后执行顺序,或者说依赖关系。再用下图说明一下

NSConditionLock精确控制线程顺序/依赖关系

上面介绍的这几个NS开头的锁,都是属于苹果Foundation框架的,没有开源,但是我们可以参考GNU_Foundation来大致了解这几个NS锁的内部实现。关于GNU是什么,请自己科普。

(六)dispatch_queue(DISPATCH_QUEUE_SERIAL)

GCD的串行队列也可以实现多线程同步,而且它并不是通过加锁来实现的。线程同步本质上就是需要多个线程按照顺序,线性的,一个接一个的去执行,而GCD的串行队列正好就是用来做这个的。下面直接通过代码案例来演示一下

@interface GCDSerialQueueVC ()
@property (nonatomic, assign) NSInteger ticketsCount;
@property (nonatomic, assign) NSInteger money;
//卖票串行队列,卖票操作都放在这个队列
@property (strong, nonatomic) dispatch_queue_t ticketQueue;
//存取钱串行队列,存钱取钱操作都放在这个队列
@property (strong, nonatomic) dispatch_queue_t moneyQueue;
@end

@implementation GCDSerialQueueVC

- (void)viewDidLoad {
    [super viewDidLoad];
    self.ticketQueue = dispatch_queue_create("ticketQueue", DISPATCH_QUEUE_SERIAL);
    self.moneyQueue = dispatch_queue_create("moneyQueue", DISPATCH_QUEUE_SERIAL);
    [self moneyTest];
    [self sellTicketTest];
}


//存钱取钱问题
-(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 {
    
    dispatch_sync(_moneyQueue, ^{//存钱操作放入存取钱队列
        NSInteger oldMoney = self.money;
        sleep(.2);//模拟任务时长,便于问题显现
        oldMoney += 50;
        self.money = oldMoney;
        NSLog(@"存了50元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
    });
}

-(void)drawMoney {
    
    dispatch_sync(_moneyQueue, ^{//取钱操作放入存取钱队列
        NSInteger oldMoney = self.money;
        sleep(.2);//模拟任务时长,便于问题显现
        oldMoney -= 20;
        self.money = oldMoney;
        NSLog(@"取了20元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
    });
}


//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    dispatch_sync(_ticketQueue, ^{//卖票操作放入卖票队列
        NSInteger oldTicketsCount = self.ticketsCount;
        sleep(.2);//模拟任务时长,便于问题显现
        oldTicketsCount--;
        self.ticketsCount = oldTicketsCount;
        NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    });
}

@end

(七)dispatch_semaphore

除了上面的方案,GCD还为开发者提供了dispatch_semaphore方案来处理多线程同步问题。semaphore意为“信号量”。信号量的初始值可以用来控制线程并发发访问的最大数量。信号量的初始值为1,代表同时允许1条线程访问资源,这样就可以达到线程同步的目的。下面来熟悉一下它的API:

如果我们将信号量初值设为1,那么多个线程运行示意图如下 GCD-semaphore控制线程同步

可以看到,线程会一个一个先后执行,也就是说,同一时间,只有一条线程可以执行业务代码,这就达到了线程同步的要求。据上推理,如果信号量初值设为2,同一时间,就可以有两条线程运行,相当于控制了线程并发执行的数量。那么最后,在展示一下代码案例

@interface GCDSemaphoreVC ()
@property (nonatomic, assign) NSInteger ticketsCount;
@property (nonatomic, assign) NSInteger money;

@property (strong, nonatomic) dispatch_semaphore_t semaphore;//并发测试信号量
@property (strong, nonatomic) dispatch_semaphore_t ticketSemaphore;//卖票测试信号量
@property (strong, nonatomic) dispatch_semaphore_t moneySemaphore;//存取钱测试信号量
@end

@implementation GCDSemaphoreVC

- (void)viewDidLoad {
    [super viewDidLoad];
    self.semaphore = dispatch_semaphore_create(5);
    self.ticketSemaphore = dispatch_semaphore_create(1);
    self.moneySemaphore = dispatch_semaphore_create(1);
    
    
    [self moneyTest];
    [self sellTicketTest];
    [self concurrencytest];
}


//最大并发数测试
- (void)concurrencytest
{
    for (int i = 0; i < 20; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(concurrencyOperationUnit) object:nil] start];
    }
}

- (void)concurrencyOperationUnit
{
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    
    sleep(2);
    NSLog(@"test - %@", [NSThread currentThread]);
    
    // 让信号量的值+1
    dispatch_semaphore_signal(self.semaphore);
}


//存钱取钱问题
-(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++) {//异步并发执行10次存钱操作
            [self saveMoney];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {//异步并发执行10次取钱操作
            [self drawMoney];
        }
    });
}

-(void)saveMoney {
    //📶📶📶📶📶📶信号量-1
    dispatch_semaphore_wait(_ticketSemaphore, DISPATCH_TIME_FOREVER);
    //💰💰💰💰💰💰存钱业务代码************************
    NSInteger oldMoney = self.money;
    sleep(.2);//模拟任务时长,便于问题显现
    oldMoney += 50;
    self.money = oldMoney;
    NSLog(@"存了50元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
    
    //📶📶📶📶📶📶信号量+1
    dispatch_semaphore_signal(_ticketSemaphore);
}

-(void)drawMoney {
    //📶📶📶📶📶📶信号量-1
    dispatch_semaphore_wait(_ticketSemaphore, DISPATCH_TIME_FOREVER);
    
    //💰💰💰💰💰💰取钱业务代码************************
    NSInteger oldMoney = self.money;
    sleep(.2);//模拟任务时长,便于问题显现
    oldMoney -= 20;
    self.money = oldMoney;
    NSLog(@"取了20元,账户余额%ld-------%@",(long)oldMoney, [NSThread currentThread]);
    
    //📶📶📶📶📶📶信号量-1
    dispatch_semaphore_signal(_ticketSemaphore);
}


//卖票问题
-(void)sellTicketTest {
    self.ticketsCount = 30;
    
    dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
    dispatch_async(queue, ^{
        for (int i= 0; i<10; i++) {
            [self sellTicket];
        }
        
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
    dispatch_async(queue, ^{
        for (int i = 0; i<10; i++) {
            [self sellTicket];
        }
    });
}

-(void)sellTicket {
    //📶📶📶📶📶📶信号量-1
    dispatch_semaphore_wait(_ticketSemaphore, DISPATCH_TIME_FOREVER);
    
    //🎫🎫🎫🎫🎫🎫卖票业务代码************************
    NSInteger oldTicketsCount = self.ticketsCount;
    sleep(.2);//模拟任务时长,便于问题显现
    oldTicketsCount--;
    self.ticketsCount = oldTicketsCount;
    NSLog(@"还剩%ld张票-------%@",(long)oldTicketsCount, [NSThread currentThread]);
    
    //📶📶📶📶📶📶信号量+1
    dispatch_semaphore_signal(_ticketSemaphore);
    
}

@end

(八)@synchronized
最后,我们再来介绍一种非常简单的线程同步方案——@synchronized。相信大家或多或少都使用过或者看到过这个指令。它的使用超级简单

@synchronized (lockObj) {
        /*
         加锁代码(临界区)
         */
    }

虽然使用简单,但是它是所有线程同步方案里面性能最差的。苹果非常不建议我们使用,除非你是测试环境下,否则需要很谨慎地使用。对于移动设备来说,什么最宝贵,一个是存储空间(内存),一个就是CPU资源。我们下面就通过底层来看一下@synchronized效率低下的原因。

首先,通过汇编追踪一下@synchronized的底层函数调用栈,按图中方法加上断点

然后显示汇编码(Debug` -> `Debug Workflow` -> `Always Show Disassembly 从汇编看,@synchronized实际上就是转化成了objc_sync_enterobjc_cync_exit两个函数,包括在临界区的头和尾部。这两个函数的源码可以在在objc4中的objc-sync.mm文件中找到。

int objc_sync_enter(id obj)
{
    int result = OBJC_SYNC_SUCCESS;

    if (obj) {
        SyncData* data = id2data(obj, ACQUIRE);
        assert(data);
        data->mutex.lock();
    } else {
        // @synchronized(nil) does nothing
        if (DebugNilSync) {
            _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
        }
        objc_sync_nil();
    }

    return result;
}
通过上图的分析,可以看出,@synchronized内部最终使用的是pthread_mutex_t,并且是递归的。@synchronized拿到参数obj之后,会利用函数id2data(obj, ACQUIRE);得到SyncData* data,然后通过data->mutex拿到最终的锁,最终进行pthread_mutex_t的加锁解锁操作。

下面来看一下id2data(obj, ACQUIRE);是怎么拿到SyncData* data的。

我们看到,其实id2data(obj, ACQUIRE);是将obj作为key,从哈希表/字典sDataLists里面取出对应的SyncData列表,最后经过处理,再找到目标SyncData* data
@synchronized的核心流程如下图
正式由于@synchronized内部封装了数组,字典(哈希表)、C++的数据结构等一系列复杂数据结构,导致它的实际性能特别底下,实际上是性能最低的线程同步,虽然你可能在一些牛逼框架里面看到过它被使用,但是如果你不是对底层特别熟练的话,还是按照苹果的建议,少用为妙,因为它真的很浪费性能。

同步方案性能对比

以上,我们就iOS中的各路线程同步方案体验了一遍。从原理上来说,OSSpinLock由于其不休眠特性,所以它的效率是非常高的,但是由于安全问题,苹果建议我们使用os_unfair_lock取而代之,并且效率还要高于前者。pthread_mutex是一种跨平台的解决方案,性能也不错。当然还有苹果的GCD解决方案,也是挺不错的。对于NS开头的那些OC下的解决方案,虽然本质也还是基于pthread_mutex的封装,但是由于多了一些面向对象的操作开销,效率不免要下降。性能最差的是@synchronized方案,虽然它的使用是最简单的,但因为它的底层封装了过于复杂的数据结构,导致了性能底下。经过各路大咖的实测和总结,将各种线程同步方案效率从高到低排列如下:

自旋锁和互斥锁的对比

什么情况下选择自旋锁更好?
自旋锁特点:效率高、安全性不足、占用CPU资源大,因此选择自旋锁依据原则如下:

  • 预计线程等待锁的时间很短
  • 加锁的代码(临界区)经常被调用,但是竞争的情况发生概率很小,对安全性要求不高
  • CPU资源不紧张
  • 多核处理器

什么情况下使用互斥锁更好?
互斥锁特点:安全性突出、占用CPU资源小,休眠/唤醒过程要消耗CPU资源,因此选择互斥锁依据原则如下:

  • 预计线程等待锁的时间比较长
  • 单核处理器
  • 临界区有IO操作
  • 临界区代码复杂或者循环量大
  • 临界区的竞争非常激烈,对安全性要求高

为什么iOS中几乎不用atomic

atomic是用于保证settersetter方法的原子性操作的,本质就是在getter和setter内部增加线程同步的锁。我们可以在objc源码的objc-accessors.mm中验证这一点,如下图

getter方法
setter方法

可以看到,如果属性被atomic修饰之后,getter方法和setter内部实际上就是增加了线程同步锁,而且可以看到,用的锁实质上是os_unfair_lock。这里需要理解的一个关键点是,atomic只能保证getter方法和setter方法内部的线程同步,例如对于属性@property (atomic, strong) NSMutableArray *dataArr;,所谓共享资源,指的是这个属性多对应的_dataArr成员变量。getter方法和setter方法实际上只是两段访问了_dataArr的代码段而已,atomic也仅仅能保证这两段代码被执行时候的线程同步,但是_dataArr完全有可能在其他地方被直接被访问(不通过属性访问),这是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并不能完全保证多线程安全问题。

另外由于其实在实际操作中,我们不太会多个线程同时操纵同一个属性,因此对于属性的资源抢占问题其实并不突出,另外property在iOS代码中实在是在是调用太频繁了,使用atomic就会导致锁的过度使用,太消耗CPU资源了,恰恰移动设备上稀缺的就是这个,所以使用atomic没有太多实际意义,我们完全可以针对具体会出现多线程隐患的地方直接加锁,也就是说,需要加锁的时候,再去加,这样可以更有效的使用CPU。因此,iOS里面,我们几乎不用atomicatomic主要用在mac开发当中。

多线程读写安全

我们在讨论存钱取钱的问题是,其实存钱操作和取钱操作里面都包含了对共享资源的读和写。假设我们有如下两个操作分别只包含读操作和写操作

- (void)read {
    sleep(1);
    NSLog(@"read");
}

- (void)write
{
    sleep(1);
    NSLog(@"write");
}

其实读操作的目的,只是取出数据,并不会修改数据,比如我取出来只是为了打印一下,因此多线程同时进行读操作是没问题的,不需要考虑线程同步问题。写操作是导致多线程安全问题的根本因素。我们iOS中对文件的操作,就属于典型的读写操作,会有写入文件和读取文件。对于读写安全,解决方案其实就是多读单写,需要满足一下几条:

  • 要求1:同一时间,只能有1个线程进行写的操作
  • 要求2:同一时间,允许有多个线程进行读的操作
  • 要求3:同一时间,不允许既读又写,就是说读操作和写操作之间是互斥关系

首先我们回顾一下iOS多线程同步的各种方案,可以发现,我们可以通过对写操作加锁,实现上面的【要求1】,不对读操作加锁,就可以实现【要求2】,但是没有一种方案可以在满足【要求2】的前提下实现【要求3】,体会一下。

iOS中有两种方案可以实现上述的读写安全需求

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函数。具体的使用原则非常简单

//手动创建一个并发队列
    dispatch_queue_t queue = dispatch_queue_create("rw_queue", DISPATCH_QUEUE_CONCURRENT);
    dispatch_async(queue, ^{
        /*
         读操作代码
         */
    });
    dispatch_barrier_async(queue, ^{
        /*
         写操作代码
         */
    })

下面给出一个代码案例

for (int i = 0; i < 10; i++) {
        dispatch_async(self.queue, ^{
            [self read];
        });
        
        dispatch_async(self.queue, ^{
            [self read];
        });
        
        dispatch_async(self.queue, ^{
            [self read];
        });
        
        dispatch_barrier_async(self.queue, ^{
            [self write];
        });
    }
总结一下就是

到此有关iOS中多线程的同步问题和多线程读写安全问题的解决方案,就整理到这里。

上一篇下一篇

猜你喜欢

热点阅读