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

24次阅读

共计 6806 个字符,预计需要花费 18 分钟才能阅读完成。

多线程
多线程技术大家都很了解,而且在项目中也比较常用。比如开启一个子线程来处理一些耗时的计算,然后返回主线程刷新 UI 等。首先我们先简单的梳理一下常用到的多线程方案。具体的用法这里我就不说了,每一种方案大家可以去查一下,网上教程很多。
常见的多线程方案
我们比较常用的是 GCD 和 NSOperation,当然还有 NSThread,pthread。他们的具体区别我们不详细说,给出下面这一个表格,大家自行对比一下。

容易混淆的术语
提到多线程,有一个术语是经常能听到的,同步,异步,串行,并发。
同步和异步的区别,就是是否有开启新的线程的能力。异步具备开启线程的能力,同步不具备开启线程的能力。注意,异步只是具备开始新线程的能力,具体开启与否还要跟队列的属性有关系。
串行和并发,是指的任务的执行方式。并发是任务可以多个同时执行,串行之能是一个执行完成后在执行下一个。
在面试的过程中可能被问到什么网情况下会出现死锁的问题,总结一下就是使用 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);
读写安全方案
上面我们讲到的线程同步方案都是每次只允许一个线程访问,在实际的情况中,读写的同步方案应该下面这样:

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

这就是多读单写,用于文件读写的操作。在我们的 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 操作的限制。也就是说,这个属性只能说是读 / 写安全的,但并不是线程安全的,因为别的线程还能进行读写之外的其他操作。线程安全需要开发者自己来保证。

正文完
 0