多线程(七)锁

在上篇介绍了线程安全隐患后,也说明了同步的工具:

  • 原子操作;
  • 内存屏障和volatile变量;

这篇文章会着重介绍各种各样的锁,以及它们之间的区别与联系。

本文介绍的锁以及其他的同步方案,如下图所示:

在介绍以上同步方案的时候,示例仍然以上篇文章中提出的示例——存钱取钱和卖票为例,进行各种同步方案的演示,本文代码——线程同步方案

一、pthread

在前面,我们了解pthread是一套基于C语言通用的线程API。

使用pthread需要注意资源的销毁,防止内存泄漏。

1.1 互斥锁

互斥锁,获得锁的线程对资源拥有访问权,在其他线程想继续访问该资源,必须等待。多线程之间体现的是一种竞争,我离开了,通知你进来。用于防止资源读写竞争关系。

image-20181230162354586

1.2 递归锁

pthread_mutex可以指定锁的类型,当如下指定为递归,即可获得递归锁。

image-20181230163926767

递归锁只是互斥锁的一个变体,同样只能有一个线程访问该对象,但允许同一个线程在未释放其拥有的锁时反复对该锁进行加锁操作。

递归锁,常用于递归调用中,如下:

1
2
3
4
5
6
7
8
9
10
11
- (void)otherTest
{
pthread_mutex_lock(&_mutex);
static int count = 0;
if (count < 10) {
count++;
[self otherTest];
}

pthread_mutex_unlock(&_mutex);
}

或者是在加锁代码中,又调用其他方法,而其他方法也进行了加锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)otherTest
{
pthread_mutex_lock(&_mutex);
[self otherTest];
pthread_mutex_unlock(&_mutex);
}

- (void)otherTest2
{
pthread_mutex_lock(&_mutex);
NSLog(@"%s", __func__);
pthread_mutex_unlock(&_mutex);
}

1.3 条件锁

​ 条件锁,是互斥锁的另一个变体。

​ 条件锁,体现的是一种协作,我准备好了,通知你开始吧,一般用于线程同步,只共同完成一个任务。这种协作关系,最经典的就是”生产者-消费者”。

​ 具体在实际应用中,允许开发者在代码中指定一个”条件”,线程A等待某个条件才能加锁。线程B在访问临界资源后释放锁,通过”条件”来唤醒线程A。

image-20181230170221419

如下是”生产者消费者”的一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (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);
if (self.data.count == 0) {
// 等待
pthread_cond_wait(&_cond, &_mutex);
}
[self.data removeLastObject];
pthread_mutex_unlock(&_mutex);
}

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

二、GCD

在介绍为pthread方案后,GCD也为我们提供了一些解决方案。

2.1 dispatch_queue

串行队列的特性,为我们线程同步提供了一种方案。

由于串行队列所有的任务都是依序一个接着一个的执行,所以也就直接从源头避免了多线程,当然就不存在线程同步的问题。

image-20181230172349253

2.2 dispatch_semaphore

dispatch_semaphore就是信号量,关于信号量我们要重新梳理下这个概念。

信号量是一个特殊的变量,程序对其访问都是原子操作,且只允许对它进行等待(即P(信号变量))和发送(即V(信号变量))信息操作。最简单的信号量是只能取0和1的变量,这也是信号量最常见的一种形式,叫做二进制信号量。而可以取多个正整数的信号量被称为通用信号量。这里主要讨论二进制信号量。

2.2.1 信号量工作原理

​ 信号量只有两种操作:等待和发送,即P(s)和V(s),其中s就是可用资源的数目:

P(s):若s>0,就s=s-1;

​ 若s=0,就挂起该进程;

V(s):若有其他进程因等待s而被挂起,就让它恢复运行;

​ 若没有进程因等待sv而挂起,s=s+1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct samephore {
int count;
queueType queue;
}

//P操作
procedure P(var s:samephore) {
s.count--;
if (s.count<0) {
//该进程状态置为阻塞状态,将该进程插入相应的等待队列s.queue的尾部;
//重新调度
asleep(s.queue);
}
//执行
}

//V操作
procedure V(var s:samephore);
{
s.count++;
if (s.count<=0){
//唤醒s.queue的头部进程
//改变其状态为就绪态,将其插入就绪队列
wakeup(s.queue);
}
}

  • s>0 表示有临界资源可供使用,为什么不唤醒进程?

    ​ s>0 表示当前有临界资源可用,没有进程会被阻塞在这个资源上,所以不需要唤醒。

  • s<0 表示无可用的临界资源,为什么还要唤醒进程?

    ​ V原语操作的本质在于:一个进程使用完临界资源后,释放临界资源,使s加1,以通知其它的进程,这个时候如果s<0,表明有进程阻塞在该类资源上,因此要从阻塞队列里唤醒一个进程来“转手”该类资源。

    ​ 比如,有两个某类资源,四个进程A、B、C、D要用该类资源,最开始s=2,当A进入,s=1,当B进入s=0,表明该类资源刚好用完, 当C进入时s=-1,表明有一个进程被阻塞了,D进入,s=-2。当A用完该类资源时,进行V操作,s=-1,释放该类资源,因为s<0,表明有进程阻塞在该类资源上,于是唤醒一个。

  • s的绝对值表示等待的进程数,同时又表示临界资源,这到底是怎么回事?

    当s<0时,其绝对值表示系统中因请求该类资源而被阻塞的进程数目;

    当s>0时,表示可用的临界资源数。注意在不同情况下所表达的含义不一样。

    当s=0时,表示刚好用完。

2.2.2 API

image-20181230220843267

2.3 dispatch_barrier_***

另外一种方式采用GCD栅栏函数,即dispatch_barrier_asyncdispatch_barrier_sync,这个在后面读写安全中会详述。

三、Cocoa

在macOS和iOS中,将pthread进行了面向对象的封装。

3.1 OSSpinLock

image-20181230222159922

OSSpinLock,是一种自旋锁,其性能非常好,但是会出现优先级反转的文档。所以iOS10之后,苹果已经废弃该锁,并推出了新的锁os_unfair_lock

3.2 os_unfair_lock

image-20181230222502586

os_unfair_lock取代的是OSSpinLock,那么它是如何解决优先级反转的呢。

os_unfair_lock锁中存储了线程所有者的相关信息,系统利用这些信息用来尝试解决优先级反转。

另外需要注意的是,os_unfair_lock解锁需要和加锁的处于相同线程中,假如不相同,可能会导致线程中止。

3.3 NSLock、NSRecursiveLock

image-20181231005700199

3.4 NSCondition

image-20181231005742659

3.5 NSConditionLock

image-20181231005759466

3.6 @synchronized

image-20181230222110593

四、读写安全

4.1 读写安全的场景

image-20181231010127324

占位:补上图。参考面试部分的图。

4.2 pthread_rwlock

image-20181231010215658

4.3 dispatch_barrier_async

image-20181231010333305

五、锁的分类与对比

5.1 分类

我们从上篇文章摘取如下:

描述 实例
互斥锁 1. 互斥锁作为资源周围的保护屏障,是一种一次只允许访问一个线程的信号量。
2. 如果一个互斥体正在使用,而另一个线程试图获取它,则该线程将阻塞,直到互斥体被其原始持有者释放。
pthread_mutex
递归锁 1. 递归锁是互斥锁的变体。递归锁允许单个线程在释放之前多次获取锁定。其他线程会一直处于阻塞状态,直到锁的所有者释放该锁的次数与获取它的次数相同。
2. 递归锁主要在递归迭代期间使用,但也可能在多个方法需要分别获取锁的情况下使用。
NSRecursiveLock
读写锁 1. 读写锁也被称为共享排他锁。这种类型的锁通常用于较大规模的操作,如果经常读取受保护的数据结构并偶尔进行修改,则可显著地提高性能。
2. 在正常操作期间,多线程可同时访问数据结构。然而,当一个线程想要写入该结构时,它会阻塞,直到所有的读取器释放该锁,此时它获得锁并可以更新该结构。写入线程正在等待锁定时,新的读取器线程将阻塞,直到写入线程完成。
pthread_rwlock_t
分布锁 分布式锁在进程级别提供互斥访问。与真正的互斥锁不同,分布式锁不会阻塞进程或阻止进程运行。它只是报告锁何时忙,并让流程决定如何继续。
自旋锁 1. 自旋锁反复轮询其锁定条件,直到该条件成立。
2. 自旋锁最常用于预计等待锁定时间较短的多处理器系统。在这些情况下,轮询通常比拦截线程更有效,后者涉及上下文切换和线程数据结构的更新。
OSSpinLock

5.2 对比

5.2.1 自旋锁与互斥锁

针对以上的锁的分类,我们仍然需要重点说明自旋锁与互斥锁:

  • 互斥锁:当一个全局资源被多个线程访问且面临竞争时,需要互斥锁来解决这一竞争问题来保证访问这一全局资源的原子性即数据独立性不被破坏,也就是说并不是有多个线程访问同一全局资源就需要使用互斥锁。

    如果一个线程无法获取互斥量,该线程会被直接挂起,不再消耗CPU时间,当其他线程释放互斥量后,操作系统会激活被挂起的线程。

  • 自旋锁:是一种用于保护多线程共享资源的锁,与一般互斥锁不同之处在于当自旋锁尝试获取锁时以忙等待(busy waiting)的形式不断地循环检查锁是否可用(可以通过汇编分析忙等)。

    在多CPU的环境中,对持有锁较短的程序来说,使用自旋锁代替一般的互斥锁往往能够提高程序的性能。

    使用自旋锁时要注意:

    • 由于自旋时不释放CPU,因而持有自旋锁的线程应该尽快释放自旋锁,否则等待该自旋锁的线程会一直在哪里自旋,这就会浪费CPU时间。
    • 持有自旋锁的线程在sleep之前应该释放自旋锁以便其他线程可以获得该自旋锁。内核编程中,如果持有自旋锁的代码sleep了就可能导致整个系统挂起。

整理如下图:

image-20181231005954791

5.2.2 性能对比

我们通过线程方案性能对比测试了不同同步方案之间的性能,如下图所示:

image-20181231005841612

测试中发现,性能明显分为三个等级:

  • 第一梯队:OSSpinLock及其替代者os_unfair_lock,信号量
  • 第二梯队:pthread_mutex及其封装后的NSLock、NSRecursiveLock、NSCondition
  • 第三梯队:串行队列、@synchronized以及NSConditionLock。

并且在测试中发现,同梯队内可能会有浮动,但是梯队之间泾渭分明。

面对这么多同步方案,了解性能的差异,我们还需要给出一些小建议,如何使用它们。

5.3 使用

  • 多核处理器:如果预计线程等待锁的时间比较短,短到比线程两次切换上下文的时间还要少的情况下,自旋锁是更好的选择。如果时间比较长,则互斥锁是比较好的选择。

  • 单核处理器:一般不建议使用自旋锁。因为在同一时间片内只有一个线程处在运行状态,如果线程a已经获取到锁,但是此时时间片轮到线程b执行,但是线程b获取锁,只能等待解锁,但是因为自己不挂起,所以线程a无法进入运行状态,只能等到线程b的时间片消耗完,线程a才有机会被OS调度执行。此时使用自旋锁的代价很高。

  • 如果加锁的代码经常被调用,但是竞争发生的比较少时,应该优先考虑使用自旋锁,毕竟互斥锁的切换上下文的开销比较大。

推荐方案:信号量、pthread系列锁,或者其面向对象的NSLock系列锁。

参考

链接

  1. Threading Programming Guide

示例代码

  1. 线程同步方案
  2. 线程方案性能对比
  3. 读写安全