多线程、锁和线程同步方案

多线程

多线程技术大家都很了解,而且在项目中也比较常用。比如开启一个子线程来处理一些耗时的计算,然后返回主线程刷新UI等。首先我们先简单的梳理一下常用到的多线程方案。具体的用法这里我就不说了,每一种方案大家可以去查一下,网上教程很多。

常见的多线程方案

我们比较常用的是GCD和NSOperation,当然还有NSThread,pthread。他们的具体区别我们不详细说,给出下面这一个表格,大家自行对比一下。

对比.png

容易混淆的术语

提到多线程,有一个术语是经常能听到的,同步,异步,串行,并发

同步和异步的区别,就是是否有开启新的线程的能力。异步具备开启线程的能力,同步不具备开启线程的能力。注意,异步只是具备开始新线程的能力,具体开启与否还要跟队列的属性有关系。

串行和并发,是指的任务的执行方式。并发是任务可以多个同时执行,串行之能是一个执行完成后在执行下一个。

在面试的过程中可能被问到什么网情况下会出现死锁的问题,总结一下就是使用sync函数(同步)往当前的串行对列中添加任务时,会出现死锁。

多线程的安全隐患

多线程和安全问题是分不开的,因为在使用多个线程访问同一块数据的时候,如果同时有读写操作,就可能产生数据安全问题。

所以这时候我们就用到了锁这个东西。

其实使用锁也是为了在使用多线程的过程中保障数据安全,除了锁,然后一些其他的实现线程同步来保证数据安全的方案,我们一起来了解一下。

线程同步方案

下面这些是我们常用来实现线程同步方案的。

OSSpinLock
os_unfair_lock
pthread_mutex
NSLock
NSRecursiveLock
NSCondition
NSConditinLock
dispatch_semaphore
dispatch_queue(DISPATCH_QUEUE_SERIAL)
@synchronized

可以看出来,实现线程同步的方案包括各种锁,还有信号量,串行队列。

我们只挑其中不常用的来说一下使用方法。
下面是我们模拟了存钱取钱的场景,下面是加锁之前的代码,运行之后肯定是有数据问题的。

/**
 存钱、取钱演示
 */
- (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 {
    int oldMoney = self.money;
    sleep(.2);
    oldMoney += 50;
    self.money = oldMoney;
    
    NSLog(@"存50,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
    
}

/**
 取钱
 */
- (void)__drawMoney {
    
    int oldMoney = self.money;
    sleep(.2);
    oldMoney -= 20;
    self.money = oldMoney;
    
    NSLog(@"取20,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
    
}

加锁的代码,涉及到锁的初始化、加锁、解锁这么三部分。我们从OSSpinLock开始说。

OSSpinLock自旋锁

OSSpinLock叫做自旋锁。那什么叫自旋锁呢?其实我们可以从大类上面把锁分为两类,一类是自旋锁,一类是互斥锁。我们通过一个例子来区分这两类锁。

如果线程A率先到达加锁的部分,并成功加锁,线程B到达的时候会因为已经被A加锁而等待。如果是自旋锁,线程B会通过执行一个循环来实现等待,我们不用管它循环执行了什么,只要知道他在那"转圈圈"等着就行。如果是互斥锁,那线程B在等待的时候会休眠。

使用OSSpinLock需要导入头文件#import <libkern/OSAtomic.h>

//声明一个锁
@property (nonatomic, assign) OSSpinLock lock;

// 锁的初始化
self.lock = OS_SPINLOCK_INIT;

在我们这个例子中,存钱取钱都是访问了money,所以我们要在存和取的操作中使用同一个锁。

/**
 存钱
 */
- (void)__saveMoney {
    OSSpinLockLock(&_lock);
    
    //....省去中间的逻辑代码
    
    OSSpinLockUnlock(&_lock);
}

/**
 取钱
 */
- (void)__drawMoney {
    OSSpinLockLock(&_lock);
    
    //....省去中间的逻辑代码
    
    OSSpinLockUnlock(&_lock);
}

这就是简单的自旋锁的使用,我们发现在使用的过程中,Xcode一直提醒我们这个OSSpinLock被废弃了,让我们使用os_unfair_lock代替。OSSpinLock之所以会被废弃是因为它可能会产生一个优先级反转的问题。

具体来说,如果一个低优先级的线程获得了锁并访问共享资源,那高优先级的线程只能忙等,从而占用大量的CPU。低优先级的线程无法和高优先级的线程竞争(CPU会给高优先级的线程分配更多的时间片),所以会导致低优先级的线程的任务一直完不成,从而无法释放锁。

os_unfair_lock的用法跟OSSpinLock很像,就不单独说了。

pthread_mutex
Default

一看到这个pthread我们应该就能知道这是一种跨平台的方案了。首先还是来看用法。

//声明一个锁
@property (nonatomic, assign) pthread_mutex_t lock;

//初始化
pthread_mutex_init(pthread_mutex_t *restrict _Nonnull, const pthread_mutexattr_t *restrict _Nullable)

我们可以看到在初始化锁的时候,第一个参数是锁的地址,第二个参数是一个pthread_mutexattr_t类型的地址,如果我们不传pthread_mutexattr_t,直接传一个NULL,相当于创建一个默认的互斥锁。

//方式一
pthread_mutex_init(mutex, NULL);
//方式二
// - 创建attr
pthread_mutexattr_t attr;
// - 初始化attr
pthread_mutexattr_init(&attr);
// - 设置attr类型
pthread_mutexattr_settype(&attr,PTHREAD_MUTEX_DEFAULT);
// - 使用attr初始化锁
pthread_mutex_init(&_lock, &attr);
// - 销毁attr
pthread_mutexattr_destroy(&attr);

上面两个方式是一个效果,那为什么使用attr,那就说明除了default类型的还有其他类型,我们后面再说。

在使用的时候用pthread_mutex_lock(&_lock);pthread_mutex_unlock(&_lock);加锁解锁。

NSLock就是对这种普通互斥锁的OC层面的封装。

RECURSIVE 递归锁

调用pthread_mutexattr_settype的时候如果类型传入PTHREAD_MUTEX_RECURSIVE,会创建一个递归锁。举个例子吧。

// 伪代码
-(void)test {
    lock;
    [self test];
    unlock;
}

如果是普通的锁,当我们在test方法中,递归调用test,应该会出现死锁,因为被lock,在递归调用时无法调用,一直等待。但是如果锁是递归锁,他会允许同一个线程多次加锁和解锁,就可以解决这个问题了。

NSRecursiveLock是对递归锁的封装。

Condition 条件锁

我们直接上这种锁的使用方法,

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

// 线程1
// 删除数组中的元素
- (void)__remove {
    pthread_mutex_lock(&_mutex);
    NSLog(@"__remove - begin");
    
    if (self.data.count == 0) {
        // 等待
        pthread_cond_wait(&_cond, &_mutex);
    }
    [self.data removeLastObject];
    NSLog(@"删除了元素");
    
    pthread_mutex_unlock(&_mutex);
}

// 线程2
// 往数组中添加元素
- (void)__add {
    pthread_mutex_lock(&_mutex);
    
    sleep(1); 
    [self.data addObject:@"Test"];
    NSLog(@"添加了元素");
    
    // 信号
    pthread_cond_signal(&_cond);
    // 广播
//    pthread_cond_broadcast(&_cond);
    
    pthread_mutex_unlock(&_mutex);
}

我们创建了两个线程,一个往数组中添加数据,一个删除数据,我们通过这个条件锁实现的效果就是在数组中还没有数据的时候等待,数组中添加了一个数据之后在进行删除。

条件锁就是互斥锁+条件。我们声明一个条件并初始化。

@property (assign, nonatomic) pthread_cond_t cond;
//使用完后也要pthread_cond_destroy(&_cond);
pthread_cond_init(&_cond, NULL);

__remove方法中

if (self.data.count == 0) {
    // 等待
    pthread_cond_wait(&_cond, &_mutex);
}

如果线程1率先拿到所并加锁,执行到上面代码这里发现数组中还没有数据,就执行pthread_cond_wait,此时线程1会暂时放开_mutex这个锁,并在这休眠等待。

线程2在__add方法中最开始因为拿不到锁,所以等待,在线程1休眠放开锁之后拿到锁,加锁,并执行为数组添加数据的代码。添加完了之后会发个信号通知等待条件的线程,并解锁。

    pthread_cond_signal(&_cond);
    
    pthread_mutex_unlock(&_mutex);

线程2执行了pthread_cond_signal之后,线程1就收到了通知,退出休眠状态,继续执行下面的代码。

这个地方可能有人会有疑问,是不是线程2应该先unlock再cond_dingnal,其实这个地方顺序没有太大差别,因为线程2执行了pthread_cond_signal之后,会继续执行unlock代码,线程1收到signal通知后会推出休眠状态,同时线程1需要再一次持有这个锁,就算此时线程2还没有unlock,线程1等到线程2 unlock 的时间间隔很短,等到线程2 unlock 后线程1会再去持有这个锁,并加锁。

NSCondition就是OC层面的条件锁,内部把mutex互斥锁和条件封装到了一起。NSConditionLock其实也差不多,NSConditionLock可以指定具体的条件,这两个OC层面的类的用法大家可以自行上网搜索。

dispatch_semaphore 信号量
@property (strong, nonatomic) dispatch_semaphore_t semaphore;
//初始化
self.semaphore = dispatch_semaphore_create(5);

在初始化一个信号的的过程中传入dispatch_semaphore_create的值,其实就代表了允许几个线程同时访问。

再回到之前我们存钱取钱这个例子。

self.moneySemaphore = dispatch_semaphore_create(1);

我们一次只允许一个线程访问,所以在初始化的时候传1。下面就是使用方法。

- (void)__drawMoney
{
    dispatch_semaphore_wait(self.moneySemaphore, DISPATCH_TIME_FOREVER);
    
    // ... 省略代码
    
    dispatch_semaphore_signal(self.moneySemaphore);
}

- (void)__saveMoney
{
    dispatch_semaphore_wait(self.moneySemaphore, DISPATCH_TIME_FOREVER);
    
    // ... 省略代码
    
    dispatch_semaphore_signal(self.moneySemaphore);
}

dispatch_semaphore_wait是怎么上锁的呢?
如果信号量>0的时候,让信号量-1,并继续往下执行。
如果信号量<=0的时候,休眠等待。
就这么简单。

dispatch_semaphore_signal让信号量+1。

小提示
在我们平时使用这种方法的时候,可以把信号量的代码提取出来定义一个宏。

#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);

读写安全方案

上面我们讲到的线程同步方案都是每次只允许一个线程访问,在实际的情况中,读写的同步方案应该下面这样:

  1. 每次只能有一个线程写
  2. 可以有多个线程同时读
  3. 读和写不能同时进行

这就是多读单写,用于文件读写的操作。在我们的iOS中可以用下面这两种解决方案。

pthread_rwlock 读写锁

这个读写锁的用法很简单,跟之前的普通互斥锁都差不多,大家随便搜一下应该就能搜到,我就不拿出来写了,这里主要是提一下这种锁,大家以后有需要的时候可以用。

dispatch_barrier_async 异步栅栏

首先在使用这个函数的时候,我们要用自己创建的并发队列。
如果传入的是一个串行队列或者全局的并发队列,那dispatch_barrier_async等同于dispatch_async的效果。

self.queue = dispatch_queue_create("rw_queue", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(self.queue, ^{
    [self read];
});
        
dispatch_barrier_async(self.queue, ^{
    [self write];
});

在读取数据的时候,使用dispatch_async往对列中添加任务,在写数据时,用dispatch_barrier_async添加任务。

dispatch_barrier_async添加的任务会等前面所有的任务都执行完,他再执行,而且他执行的时候,不允许有别的任务同时执行。

atomic

我们都知道这个atomic是原子性的意思。他保证了属性setter和getter的原子性操作,相当于在set和get方法内部加锁。

atomic修饰的属性是读/写安全的,但不是线程安全。

假设有一个 atomic 的属性 "name",如果线程 A 调用 [self setName:@"A"],线程 B 调用 [self setName:@"B"],线程 C 调用 [self name],那么所有这些不同线程上的操作都将依次顺序执行——也就是说,如果一个线程正在执行 getter/setter,其他线程就得等待。因此,属性 name 是读/写安全的。

但是,如果有另一个线程 D 同时在调[name release],那可能就会crash,因为 release 不受 getter/setter 操作的限制。也就是说,这个属性只能说是读/写安全的,但并不是线程安全的,因为别的线程还能进行读写之外的其他操作。线程安全需要开发者自己来保证。

阅读 1.7k

推荐阅读

记录技术学习和进阶的过程

3 人关注
28 篇文章
专栏主页