读写锁中写锁的饥饿问题

读写锁是个看上去很美的的抽象

代码是程序员对这个世界的抽象过程,是的,好的代码就像语言,将各个组件之间的关系描述的一清二楚,而读写锁在其中就是一个很好的例子,正如开头的一句引用,这是个看上去很美的抽象,陈硕前辈在muduo中给出的建议是:**不使用读写锁.**为什么呢,陈硕前辈给出的原因是以下几点:

  1. 在读锁的地方不小心修改了保护的变量,这与无保护结果是一样的.
  2. 读锁可能可以提升为写锁,这可能使得用它保护的数据中出现迭代器失效的情况.
  3. 读写锁在性能方面不一定优于互斥锁,这很好理解,因为读写锁内部本身就要进行一部分运算,如果竞争不激烈的话,且锁的粒度较小,那么效率其实并没有多少提升
  4. 写锁饥饿问题
  5. 为了避免写锁饥饿读锁可能出现的死锁(这个我不清楚)

因为以上原因,陈硕前辈不推荐使用读写锁,但是依我拙见,在读任务远大于写任务时,还是使用读写锁来提升性能,这不也是它被创造出来的意义吗,而以上提到的问题大多是可以避免的.

写锁饥饿问题

在读线程多于写线程时,如果仅靠内核的调度,可能出现写线程的饥饿问题,其实大多标准库中避免了这个问题,接下来我们就来看看在C++中一种模拟实现的类读写锁和JDK8中引入的StampedLock这两种实现是如何避免写锁饥饿问题的.

copy-on-write

我们首先来看一段简单的小demo

std::mutex mutex_;
std::shared_ptr<data> G_data(new data());

void read(){
	std::shared_ptr<data> Temp;
	{
		std::lock_guard<std::mutex> guard(mutex_);
		Temp = G_data;
	}
	Temp->read_something();
}

void write(){
    std::lock_guard<std::mutex> guard(mutex_);
    if(!G_data.unique()){
        G_data.reset(new data(*G_data));
    }
    G_data->write_something();
}

好啦,一个简约而不简单的读写锁完成了,你可能会感到疑惑,这是怎么一回事呢,我们抛出一个假设来仔细看一看:
假设我们现在有一个ThreadA为读线程,有一个ThreadB为写线程,ThreadA去执行read(),其中首先把构造防止锁外,减小锁的粒度以及保证异常安全,在得到锁后进行赋值,此时G_data引用计数为2,现在ThreadA执行至read_something()之前,guard作用域之后.ThreadB此时得到锁.进行unique(),因为ThreadA执行了拷贝,所以unique()返回false,进入if中,这就是关键所在,进行拷贝,并对G_data进行reset,此时两个线程持有的是不同的shared_ptr,ThreadA对旧数据进行读取,而ThreadB对新数据进行修改,这时显然其它再有的读线程无法持有锁,其持有锁时ThreadB已经修改完毕,这样就做到了避免写锁中的饥饿问题,有些读者看到后可能会觉得眼熟,没错,这其实类似于linux内核中的RCU(read-copy-update)锁,RCU的核心理念其实就是读线程访问时,写线程可以去更新保护数据的副本,但写线程需要等待所有读线程完成读取后,才可以删除老对象.哇偶,这不就是上面所写的那寥寥几行代码吗,ThreadB更新副本,ThreadA或者ThreadCDE读取完成后智能指针计数器递减,到零时自动删除,所以以上的代码其实就是用C++去实现的一个用户态的读写锁,但真正的RCU是怎么回事呢,笔者水平有限,还未看过源码,没办法给出详细的讲解,那也不是本篇文章的重点,但有意思的是其中一种实现就是阶段计数器,分为new和old两个阶段,写时更新本线程中的阶段为new,保当old中计数器为零时删除

StampedLock

JDK8中中引入了StampedLock,是对读写锁ReentrantReadWriteLock的增强,那么StampedLock是如何避免写锁的饥饿问题的呢?我们就来看看吧

首先像所有介绍StampedLock的文章一样贴上官方文档上展示代码.

 class Point {
   private double x, y;
   private final StampedLock sl = new StampedLock();

   void move(double deltaX, double deltaY) { // an exclusively locked method
     long stamp = sl.writeLock();
     try {
       x += deltaX;
       y += deltaY;
     } finally {
       sl.unlockWrite(stamp);
     }
   }

   double distanceFromOrigin() { // A read-only method
     long stamp = sl.tryOptimisticRead();
     double currentX = x, currentY = y;
     if (!sl.validate(stamp)) {
        stamp = sl.readLock();
        try {
          currentX = x;
          currentY = y;
        } finally {
           sl.unlockRead(stamp);
        }
     }
     return Math.sqrt(currentX * currentX + currentY * currentY);
   }

   void moveIfAtOrigin(double newX, double newY) { // upgrade
     // Could instead start with optimistic, not read mode
     long stamp = sl.readLock();
     try {
       while (x == 0.0 && y == 0.0) {
         long ws = sl.tryConvertToWriteLock(stamp);
         if (ws != 0L) {
           stamp = ws;
           x = newX;
           y = newY;
           break;
         }
         else {
           sl.unlockRead(stamp);
           stamp = sl.writeLock();
         }
       }
     } finally {
       sl.unlock(stamp);
     }
   }
 }

代码不难理解,我们主要来看看distanceFromOrigin()这个函数:
其中有意思的是tryOptimisticRead这个方法,就像方法名一样,尝试乐观读取,也就是先获取一个乐观锁,然后validate检测在这期间是否有写操作,如果有的话进行readLock(),获取读锁,否则直接执行.

StampedLock是如何避免饥饿的呢,其实就是其中实现了一个队列,每次不管是读锁也好写锁也好,未拿到锁就加入队列,然后每次解锁后队列头存储的线程节点获取锁,以此避免饥饿,具体请点这里.

总结

说了这么多,对于读写锁我的建议是使用,毕竟在读事件远大于写时间时效率提升确实显著,但是这个"远大于的"的定义我们该怎么下呢,显然没有一个公平的评判标准,所以在遇到我们认为需要读写锁的场景,两种锁都进行测试才是王道.还有一点需要陈述,就是有些读写锁在读写优先级上可能有所偏重(这当然也是避免饥饿的一种方法),所以在使用前我们需要清楚我们的程序需要的是什么以及库中的实现是什么,这样才能写出一个响应性更优的代码.


2020年5月19日:
对这个问题有了一点新的理解,记录成这篇文章《读者写者问题的优先级与读写锁的实际解决方案》,两篇结合起来会对这个问题的看法更深一些。

参考:
https://juejin.im/post/5bacf523f265da0a951ee418#comment
https://blog.csdn.net/dog250/article/details/46848649
https://blog.csdn.net/ztguang/article/details/71157742
https://cloud.tencent.com/developer/article/1470988

posted @ 2022-07-02 13:18  李兆龙的博客  阅读(355)  评论(0编辑  收藏  举报