并发显式锁之读写锁

时间:2022-06-21
本文章向大家介绍并发显式锁之读写锁,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

上一篇文章我们介绍了一个显式锁,ReentrantLock ,了解到它是一个『独占式』锁,简而言之就是,

我拿到锁以后,不管我是读或是写操作,其他人都不能和我抢,都得等着。

因而在某些读操作远大于写操作的场景之下,即便我只是读数据也不得不排队一个一个来,于是有人提出了一个『读写锁』的概念。

『读写锁』并不是真正意义上的读写分离,它只允许读读共存,而读写、写写依然是互斥的,所以只有在大量读操作、少量甚至没有写操作的情境之下,读写锁才具有较高的性能体现。

类的基本结构

来自父接口的规范

ReentrantReadWriteLock 继承了接口 ReadWriteLock,而父接口约束它必须提供的能力如下:

而 ReentrantReadWriteLock 对该接口的实现也是简单明了了的:

显然,ReentrantReadWriteLock 通过在内部定义两个静态内部类来分别实现接口 Lock,以达到内嵌读写锁的能力,而两个内部类的实现是如何的?区别在哪?怎么实现一个读一个写?我们稍后会详细地从源码层面一点点分析,不要着急。

自定义实现 AQS

AQS 是什么呢?相信看过之前文章的朋友是一定知道的,AQS 指的是 AbstractQueuedSynchronizer,就是一个同步容器。简而言之就是:

一个队列、一个状态、一个线程对象。

线程对象保存的当前被允许访问代码块的线程实例,队列中每一个线程都是一个节点,这些线程都是由于没能获取到锁而阻塞排队在这里。状态可以取值为零或正正整数,零表示当前无人持有该锁,正数表示当前线程多次重入该锁的次数。

除此之外,ReentrantReadWriteLock 中剩余的一些方法主要提供了该锁的一些状态信息的返回,这部分比较简单。本文的重点将放在对那两个内部类实现的读锁写锁原理的分析。

读读共存

下面我们深入到源码层面去看看读锁在何种情况下才能成功的加在临界资源上,哪些情况下不得加读锁。另外说一句,对于有些方法我并不会一跟到底,不然篇幅太长了,我会大体概括这些方法的作用与核心逻辑,具体的大家可以自行阅读分析。

ReadLock 是 ReentrantReadWriteLock 中定义的一个内部类,它实现了 Lock 接口,提供基本的 lock、unlock 等方法,我们先看 lock 方法:

public void lock() {
    sync.acquireShared(1);
}

lock 方法很简单,调用了外部类同步容器实例的同步方法,因为需要读写分离,所以读锁写锁必须共用同一个 AQS,而这个 AQS 则定义在外围类 ReentrantReadWriteLock 之中,供两种锁使用。

简而言之,无论是读锁或是写锁,他们共用的一个 AQS 同步器,同一个阻塞队列,同一个状态,同一个线程持有器。ReentrantReadWriteLock 也正是通过这个公用的 AQS 同步器来协调读锁写锁能同时工作。

acquireShared 方法实现如下,这里我们先以公平策略作引例:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

tryAcquireShared 方法实现如下:

这个方法的代码不铺开分析了,主要切三个部分总结下逻辑及完成的功能,具体源码大家自行分析了,如有疑问欢迎加我微信一起探讨(文末)。

  1. 如果有线程对临界资源加了写锁,并且该线程不是自己,那么认为自己应当退出阻塞,不能再加读锁,返回负值。
  2. 到达这里必然说明临界资源没有被任何其他写锁占用,然后这部分首先会通过 CAS 去修改状态,为读锁计数增一。除此之外,还将计算并保存当前线程重入该读锁的次数,这里的记录算法也是很有意思的,如果你有些疑惑欢迎和我讨论讨论。
  3. 第三个步骤是上两个步骤的综合,这个方法体中将循环的执行上述 1、2 两个步骤,直到成功加上读锁或是条件发生改变,不再具备尝试获取读锁的能力,例如当前的临界资源已经被写锁占用、等待队列中有其他线程正在等待向临界资源添加锁限于公平策略,当前线程不得继续竞争并尝试加锁。

分析完了 tryAcquireShared 方法以后,我们知道如果此次尝试加锁失败,方法会返回值 -1,意味着加读锁失败,当前线程需要被阻塞排队等待。

于是就有了我们的 doAcquireShared 方法,该方法会将当前线程包装成节点添加到阻塞队列尾部,排队等待再次竞争临界资源。

  1. addWaiter 会将当前线程包装成一个 Node 节点添加到队列的尾部,如果队列没有初始化会优先做初始化队列的操作。
  2. 接着在一个死循环准备阻塞当前线程,当然阻塞之前会取出当前节点的前一个节点,比较看是不是 head 节点,如果是则说明当前线程排在队列的第一位置,于是再次尝试添加读锁,如果成功方法即刻返回。
  3. 如果当前线程并没有排在队列第一的位置,亦或是再次的尝试也失败,那么将在这部分的 parkAndCheckInterrupt 方法中被阻塞。
  4. 如果上述步骤失败了,也就是 failed 的值是 true,那么将取消当前试图添加读锁的操作,删除当前线程对应阻塞队列中的节点,唤醒下一个节点对应的线程。

这样的话,我们关于读锁的加锁大致上也摸清楚了,总结一下整个过程:

首先 lock 方法会调用 tryAcquireShared 方法做一次尝试加锁操作,如果成功了那么整个加锁的过程也就结束了,否则还会去区分是什么原因导致的失败。

如果是由于临界资源正在被写锁锁住,那么认为你不应该再尝试了,先去阻塞等着吧,而如果是由于并发修改 state 导致的失败,那么将进入循环尝试,直到成功或是遇到和上述一样的情况,有写锁成功的占有了临界资源,不得继续尝试。

tryAcquireShared 失败后将导致 doAcquireShared 方法的调用,将当前线程包装成节点添加到队列的尾部,然后阻塞在循环体之中,等待别人的唤醒。

接下来我们来看看读锁的 unlock 方法实现:

类似的代码结构,我们看 tryReleaseShared 方法:

两个部分,比较简单:

  1. 将当前线程的读锁计数器自减一
  2. 循环的进行 CAS 操作,修改 state 的值,让它减一,只有当所有的读锁都释放后,此方法才会返回 true。

只有当所有的读锁都释放结束之后,该方法才会返回 true 并转而去执行方法 doReleaseShared 试图唤醒队列中下一个状态正常线程。

  1. 获取队列 head 节点,如果 head 等于 null 或是和 tail 节点相等,那么认定此队列是空队列,没有任何线程在排队也即无节点可释放,方法结束。反之,如果 head 节点的 waitstatus 是 SIGNAL,那么认为该节点已经被阻塞,调用 UnparkSuccessor 方法去唤醒 head 节点后首个有效的线程节点。
  2. 正常情况下,被阻塞的线程节点的等待状态都是 SINGLE ,如果等待状态是零,也即等于初始化默认的值,那么将修改该等待状态并结束循环。也就是这种情况的 head 节点在调用 doReleaseShared 方法是不会释放任何队列中的线程节点的。

关于第二步,很多人可能根本不知道为啥这么做,这里简单说一下:

我们的 doAcquireShared 方法尝试阻塞当前线程的过程中有这么一个过程,就是在实际阻塞之前会判断一下当前线程节点是不是排在队列的第一个,如果是则作最后一次尝试,一旦失败就真正阻塞了,成功的话会调用 setHeadAndPropagate 方法。

这个方法会将当前节点置换到 head 节点上,并且调用 doReleaseShared 将自己的 waitStatus 值改成 PROPAGATE,象征一种「传播」特性,并且队列此时没有人在排队,所以下一个读锁会无条件的成功,就这样一直传播下去,直到任一线程失败了才将头结点的传播状态修改为 SINGLE,以此释放 doReleaseShared 的释放能力。

说一下哈,有关「传播特性」,市面上分析这部分源码的文章大多都选择略过或是含糊其辞,没怎么搜索到描述详尽的资料。以上是我个人理解,各位要是有疑问,也欢迎大家和我一起交流探讨!

关于读锁的释放,我想我已经描述的很清晰了,总结下大体逻辑:

每调用一次 tryReleaseShared 都会减少一次读锁的持有数量,只有读锁的持有量为零,该方法才会返回 true,并接着调用 doReleaseShared 方法释放队列中第一个有效的阻塞节点,让它重新竞争临界资源添加读锁,这个过程本来是很简单的,就节点向前移动并唤醒线程而已,但是其中涉及了一个「传播」共享传递,需要额外去理解,这一点我们上述也做了说明了。

写写互斥

分析完了读锁的加锁和释放锁的过程,接下来我们分析写锁的添加和释放过程是如何彼此互斥工作的。

写锁的 lock 方法调用 acquire 方法:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

和读锁的尝试加锁方法具有相似的代码风格,都是先通过一个 tryXXX 方法尝试加锁,失败了就会返回调用另一个方法阻塞当前线程到等待队列上。我们先看这个 tryAcquire 方法:

如果你认真的分析了读锁的源码,你会发现写锁的尝试加锁就非常简单了。

  1. 第一部分会根据锁的状态 state 值得到当前临界资源的各种锁持有情况,如果状态为零,则说明没有任何锁在临界资源上,转而第二部尝试加锁。否则,如果有写线程正在工作并且不是自己,那么直接返回失败,不再尝试,否则就是自己重入了该临界资源了,直接无并发增加持有次数。
  2. 第二部分就是尝试加锁的过程,由于是公平策略,所以需要先做判断来判断当前线程是否有资格去竞争锁,也就是如果等待队列中有其他节点在排队,公平策略下是不允许「后来居上」的,当前线程不允许竞争。反之如果队列是空或者当前线程排在队列的第一个有效位置上,那么也认为不违反公平策略的,因为没有「插别人的队」,于是 CAS 更改 state 状态,尝试加锁。

写的分析文字有点多,但是这个尝试加锁的代码逻辑确实是简单易理解的。

我们再回到 acquire 方法:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

如果 tryAcquire 失败了,那么将调用 acquireQueued 添加当前线程到等待队列上并阻塞当前线程,我们一起看看这个方法的实现:

这个方法也是不难的,两个部分,前一个部分是做「临死挣扎」,如果自己是队列首个有效的线程节点,那么将再进行一次尝试,如果成功即刻返回而不必阻塞自己,否则将通过调用 LockSupport 中的 unpark 方法阻塞当前线程。

接着我们看写锁的释放实现逻辑:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

废话不多说,直接看 tryRelease 方法:

如果 state 中代表写锁持有数量值减去一还不等于零,那么说明当前线程多次重入该写锁,于是修改 state 的值,让写锁持有数量减一,返回 false。

否则,认为该线程的多次重入已经全部退出了,这时才会返回 true,表示写锁全部释放。

这时我们回到 release 方法,剩余的代码也已经明了了,如果返回了 true,也即写锁全部释放了,那么将唤醒队列中等待着的第一个有效结点线程,唤醒之后方法返回 true,表示写锁释放完成,否则返回 false,表示写锁释放失败,多次的重入并没有得到完全的释放。

写在最后

总的来说,写锁的加锁与释放相对于读锁来说是简单的,因为它是互斥了,没那么多条件,不管你是什么锁,只要你正在占用临界资源,那么我就等待。而相对于读锁来说,它需要去区分读线程正在使用资源、还是写线程线程正在使用资源。

所以,读写锁的复杂点在于读锁的共存,写锁是互斥的,没有过多的要求,重点在于对读锁的理解。

关注公众不迷路,一个爱分享的程序员。