likes
comments
collection
share

【锁思想】高并发下线程饥饿?看看读写锁是怎么避免饥饿的

作者站长头像
站长
· 阅读数 12

  大家好,我是Coder哥,在技术日新月异的今天,真正应该花费时间学习的是那些不变的编程思想,今天我们来接着上一篇文章来聊一下锁思想,我们上一篇”《为什么synchronized是非公平的》“详细的分析了公平锁与非公平锁的实现思想以及非公平锁固有的缺陷: 造成线程饥饿。那么今天我们再来聊一下读写锁到底是怎么解决饥饿问题。

在聊这个问题之前,我们先从以下几个问题出发:

  1. 读写锁的获取原则是什么?
  2. 读写锁在读多写少的场景中为什么非公平策略更容易造成线程饥饿?
  3. 读写锁是通过什么策略来避免写线程饥饿的?

读写锁的获取原则

我们快速的复习一下读写锁的规则,在使用读写锁时遵守下面的获取规则:

  1. 如果有线程占用读锁,则此时其他线程如果申请读锁,可以申请成功。
  2. 如果有线程占用读锁,则此时其他线程如果申请写锁,需要等待读锁释放了才可以获取,读写不能同时操作。
  3. 如果有线程占用了写锁,则此时其他线程要申请读锁或者写锁,都必须等待读锁释放了才可以获取,即写读,写写不能同时操作。

总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)。

读写锁在读多写少的场景中为什么非公平策略更容易造成线程饥饿?

在文章《为什么synchronized是非公平的》中了解到,在没有读写锁的时候,假如我们使用普通的ReentrantLock,

  1. 如果使用公平策略,那么所有竞争的线程就会排队等待,高并发下会有大量的线程进行上下文切换,带来了时间的开销。
  2. 如果用非公平策略,在读并发极大的情况下,虽然会有一定的性能提升,可能会被读线程一直插队获取锁,造成写线程一直获取不到锁的情况。

比如:线程1先获取到锁了,写线程2,3,4在排队写入数据,这个时候假如说有大量的读线程5,6,7来插队,由于读锁可以共享,那么读线程1在没释放锁的情况下,读线程5,6,7就能获取到读锁,那么就会造成写线程一直获取不到锁的情况,就是所谓的线程饥饿,那么读写锁是怎么解决这个问题的呢?

读写锁是通过什么策略来避免写线程饥饿的?

在文章 《为什么synchronized是非公平的》 中,我们知道锁的策略公平和非公平的策略,其实读写锁也分公平和非公平策略的,我们可以查阅一下ReentrantReadWriteLock的源码,知道可以设置公平和非公平策略:

公平锁:

ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);

非公平锁:

ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

既然这样,读写锁也有排队和插队机制。我们从读写锁的公平和非公平策略的实现来看一下:在获取读锁之前,线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队

先来看一下公平锁对于这两个方法的实现:

// 获取写锁时会调用这个方法,如果有线程在排队,返回true
final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}
// 获取读锁时会调用这个方法,如果有线程在排队,返回true
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

可以看到,在公平锁的情况下,会判断hasQueuedPredecessors() 即等待队列中是否有线程在等待,也就是一律不允许插队,都会去排队,这也符合公平锁的思想。

下面让我们来看一下非公平锁的实现:

// 返回 false 意思是,不用阻塞,可以直接插队
final boolean writerShouldBlock() {
    return false; 
}
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}

在 writerShouldBlock() 这个方法可以看出,对于想获取写锁的线程而言,由于返回值是 false,所以它是随时可以插队的,这就和 ReentrantLock 的非公平策略是一样的了。

但是读锁不一样。这里的策略很有意思,先让我们来看下面这种场景:

假设线程 1 和线程 2 正在同时读取,即线程1,2已经持有读锁,此时线程 3 想要获取写锁写入,但由于线程 1,2 已经持有读锁了,所以线程 3 就进入等待队列进行等待。此时,线程 4 突然跑过来想要插队获取读锁:

【锁思想】高并发下线程饥饿?看看读写锁是怎么避免饥饿的

面对这种情况有两种策略,一种是允许插队,另一种是不允许插队,基于这两种策略我们来分别分析一下(这里是重点):

第一种:允许插队

允许插队,这个看起来很合理,因为线程1,线程2都是读锁,虽然有写线程3在排队,刚好读线程们可以共用这把读锁,那么第一种策略就允许读线程4的插入和线程1,线程2一起去读取。

这种策略看上去增加了效率,但是有一个严重的问题:别忘了读写锁的场景(读多),也就是说读取的线程会不停地增加,比如读线程 5,那么线程 5 也可以插队,这样就会导致读锁长时间内不会释放,进而导致写线程 3 长时间内拿不到写锁陷入“饥饿”状态,它将在长时间内得不到执行。

【锁思想】高并发下线程饥饿?看看读写锁是怎么避免饥饿的

第二种:不允许插队

这种策略认为由于写线程 3 已经提前等待了,所以虽然读线程 4 直接插队成功可以提高效率,但是我们依然让读线程 4 去排队等待:

【锁思想】高并发下线程饥饿?看看读写锁是怎么避免饥饿的 按照这种策略读线程 4 会被放入等待队列中,并且排在写线程 3 的后面,让写线程 3 优先于读线程 4 执行,这样可以避免“饥饿”状态,这对于程序的健壮性是很有好处的,直到写线程 3 运行完毕,读线程 4 才有机会运行,这样谁都不会等待太久的时间。

所以我们可以看出,即便是非公平锁,只要等待队列的头结点是尝试获取写锁的线程,那么读锁依然是不能插队的,目的是避免“饥饿”

策略的选择取决于具体锁的实现,ReentrantReadWriteLock 的实现选择了第二种策略 ,就有效的避免了饥饿的情况。

总结

对于 ReentrantReadWriteLock 而言。

  • 插队策略,公平策略下,只能排队获取。
  • 非公平策略下:如果允许读锁插队,由于读锁可以同时被多个线程持有,可能会造成源源不断的读线程一直插队成功,导致读锁一直不能完全释放,从而导致写锁一直等待,为了防止“饥饿”,在等待队列的头结点是尝试获取写锁的线程的时候,不允许读锁插队
  • 写锁可以随时插队,因为写锁和其他锁都互斥,并不容易插队成功,写锁只有在当前没有任何其他线程持有读锁和写锁的时候,才能插队成功,同时写锁一旦插队失败就会进入等待队列,所以很难造成“饥饿”的情况,允许写锁插队是为了提高效率。

感谢看到这里,如对编程思想或者服务端全栈感兴趣,可以关注一下: 掘金或微信搜: todocoder 后面也会持续的分享编程思想以及Java和Go等全栈服务端相关的内容。 如果觉得这篇文章有用,用你发财的小手,帮忙给点个👍,感谢(^_^)