C++STL shared_mutex完结与剖析

shared_mutex简介

shared_mutex 类是一个同步原语,可用于维护同享数据不被多个线程同时拜访。

与便于独占拜访的其他互斥类型不同,shared_mutex 具有二个拜访等级:

  • 同享 – 多个线程能同享同一互斥的一切权。

  • 独占性 – 仅一个线程能占有互斥。

shared_mutex类的揭露办法包括:

C++STL shared_mutex实现与分析

std::shared_mutex是在C++17规范中引入的,std::shared_mutex的更完好描述可以在cppreference.com网页上找到。


shared_mutex语义

关于非C++规范来说,shared_mutex的更简单理解的名称是读写锁(read-write lock)。

比较于读写锁,更基础的是互斥锁,所以咱们先从互斥锁说起(互斥锁在C++规范中的名称是std::mutex)。

互斥锁会把试图进入临界区的一切其他线程都堵塞住。该临界区一般涉及对由这些线程同享的一个或多个数据的拜访或更新。可是有时候咱们可以在某个数据与修正某个数据之间作区分。这也是运用读写锁的场景条件之一。

关于在获取读写锁用于读或获取读写锁用于写之间的差异,规则如下:

  • 只需没有线程持有某个给定的读写锁用于写,那么恣意数目的线程可以持有该读写锁用于读。
  • 仅当没有线程持有某个给定的读写锁用于读或用于写时,才能分配给读写锁用于写。

换一种说法便是,只需没有线程在修正某个给定的数据,那么恣意数目的线程都可以具有该数据的读拜访权。仅当没有其他线程在读或修正某个给定的数据时,当时线程才可以修正它。

某些运用中读数据比修正数据频频,这些运用可以从改用读写锁代替互斥锁中获益。恣意给定时刻答应多个读出者存在提供了更高的并发度,同时在某个写入者修正数据期间维护该数据,避免任何其他读出者或写入者的干扰。这也是判别是否应该运用读写锁的根据之一。

这种关于某个给定资源的同享拜访也称为同享-独占(shared-exclusive)上锁(这便是shared_mutex中shared的由来),其间获取一个读写锁用于读称为同享锁(shared lock),对应的便是shared_mutex::lock_shared办法,获取一个读写锁用于写称为独占锁(exclusive lock),对应的便是shared_mutex::lock办法。有人可能有疑问,为啥独占锁对应的办法是shared_mutex::lock,而不是shared_mutex::lock_unique之类的呢?其实原因是为了跟std::mutex的lock办法名一致,而这样做的原因是为了让std::lock_guard和std::unique_lock等模板类能复用在shared_mutex上,详细细节这儿就不展开了。


shared_mutex完结

接下来,咱们将自己着手完结一个shared_mutex类。完好工程代码: github gitee

1. shared_mutex类的数据结构

shared_mutex包括如下成员变量

class shared_mutex {
private:
    std::mutex              mutex;
    std::condition_variable read;       // wait for read
    std::condition_variable write;      // wait for write
    int                     r_active;   // readers active
    int                     w_active;   // writer active
    int                     r_wait;     // readers waiting
    int                     w_wait;     // writers waiting
// ...
};
  • 用于序列化成员变量存取的互斥量(mutex)。
  • 两个独立的条件变量,一个用于等候读操作(read),一个用于等候写操作(write)。
  • 为了可以判别条件变量上是否有等候线程,咱们将保存活泼的读线程数(r_active)和一个指示活泼的写线程数(w_active)的标志。
  • 咱们也保留了等候读操作的线程数(r_wait)和等候写操作的线程数(w_wait)。

这儿w_active虽然是int类型,其实是表明一个bool标志,因为只能有一个”活动的写线程“(持有写锁),但考虑到内存对齐,这儿运用bool仍是int,其实是没不同的。

2. shared_mutex类的结构与析构。

首要是结构函数:

shared_mutex::shared_mutex():
    r_active(0), w_active(0), r_wait(0), w_wait(0)
{
}

并没有什么需求特别强调的,mutex、read、write这三个成员变量会调用默许结构函数,shared_mutex对象结构完结后,处于未加锁的状况。

然后是析构函数:

shared_mutex::~shared_mutex()
{
    assert(r_active == 0);
    assert(w_active == 0);
    assert(r_wait == 0);
    assert(w_wait == 0);
}

更是没什么好说的,仅仅加了一些断语而已。

3. shared_mutex类的获取/开释同享锁(读锁)的相关办法。

首要是lock_shared办法,为读操作获取同享锁(读锁)。

void shared_mutex::lock_shared()
{
    std::unique_lock<std::mutex> lock(mutex);
    if (w_active) {
        r_wait++;
        while (w_active) {
            read.wait(lock);
        }
        r_wait--;
    }
    r_active++;
}
  • 假如当时没有写线程是活动的,那么咱们就挂号r_active成员变量,更新活动的读线程数+1,并从lock_shared办法回来,表明获取同享锁成功。
  • 假如一个写线程当时是活动的(w_active非0),咱们就挂号r_wait成员变量,更新等候读线程的数量+1,然后wait在read条件变量上(直到写线程开释了锁,并经过read条件变量唤醒了当时线程)。
  • 别的,为了简化完结,这儿并没考虑线程wait在read条件变量时,线程被cancel的状况,这种状况下r_wait并不会-1,从而形成数据不一致。关于这种状况的处理,可以参考相关书籍,这儿就不在赘述了。

然后是try_lock_shared办法:

bool shared_mutex::try_lock_shared()
{
    std::lock_guard<std::mutex> lock(mutex);
    if (w_active) {
        return false;
    } else {
        r_active++;
        return true;
    }
}

代码逻辑跟lock_shared很像,除了:

  • 当有一个写线程活动时,它将直接回来false,而不是block在read条件变量上。
  • 假如获取同享锁(读锁)成功,回来的true。

最终是unlock_shared办法:

void shared_mutex::unlock_shared()
{
    std::lock_guard<std::mutex> lock(mutex);
    r_active--;
    if (r_active == 0 && w_wait > 0) {
        write.notify_one();
    }
}

该函数实质上是经过削减活泼的读线程数(r_active)倒置了lock_shared或try_lock_shared的效果。假如不在有活泼的读线程,并且至少有一个线程正在等候写操作,会告诉write条件变量来唤醒其间的一个。

4. shared_mutex类的获取/开释独占锁(写锁)的相关办法。

首要是lock办法,为写操作获取独占锁(写锁)。

void shared_mutex::lock()
{
    std::unique_lock<std::mutex> lock(mutex);
    if (w_active || r_active > 0) {
        w_wait++;
        while (w_active || r_active > 0) {
            write.wait(lock);
        }
        w_wait--;
    }
    w_active = 1;
}

该函数很像lock_shared,除了在write条件变量上等候的谓词条件。

  • 假如当时有任何写线程或读线程是活动的,咱们就挂号w_wait成员变量,更新等候写线程的数量+1,然后wait在write条件变量上(直到有写线程开释了锁,或许一切的读线程开释了锁,并经过write条件变量唤醒了当时线程)。
  • 否则,那么咱们就挂号w_active成员变量,更新活动的写线程标志为1(true),并从lock办法回来,表明获取独占锁成功。
  • 别的,为了简化完结,这儿并没考虑线程wait在write条件变量时,线程被cancel的状况,这种状况下w_wait并不会-1,从而形成数据不一致。关于这种状况的处理,可以参考相关书籍,这儿就不在赘述了。

然后是try_lock办法:

bool shared_mutex::try_lock()
{
    std::lock_guard<std::mutex> lock(mutex);
    if (w_active || r_active > 0) {
        return false;
    } else {
        w_active = 1;
        return true;
    }
}

代码逻辑lock很像,除了:

  • 假如读写锁当时被运用(或许被一个读线程或许一个写线程),它将直接回来false,而不是block在read条件变量上。
  • 假如获取独占锁(写锁)成功,回来的true。

最终是unlock办法:

void shared_mutex::unlock()
{
    std::unique_lock<std::mutex> lock(mutex);
    w_active = 0;
    if (r_wait > 0) {
        read.notify_all();
    } else if (w_wait > 0) {
        write.notify_one();
    }
}

该函数被一个线程调用来开释写锁。

  • 当一个写线程开释读写锁时,它总是可用的;假如有任何线程等候,有必要唤醒其间一个。
  • 这儿的完结时”读优先“的,首要寻觅正在等候的读线程。假如有,将播送read条件变量来唤醒它们。
  • 假如没有等候的读线程,可是有一个以上的写线程等候,经过告诉write条件变量来唤醒其间一个。

至此,shared_mutex类的完好完结就介绍完了。


shared_mutex运用示例

接下来,咱们经过一个shared_mutex类的运用示例,介绍shared_mutex类的同享-独占特性。

首要,咱们先给出一个完好的示例代码:

#include <iostream>
#include <string>
#include <chrono>
#include <iomanip>
#include <thread>
#include <shared_mutex>
#include "shared_mutex.hpp"
mini_stl::shared_mutex rwlock;
void thread1();
void thread2();
inline
std::ostream& operator<<(std::ostream &os,
        const std::chrono::time_point<std::chrono::system_clock> &t)
{
    const auto tt (std::chrono::system_clock::to_time_t(t));
    const auto loct (std::localtime(&tt));
    return os << std::put_time(loct, "%c");
}
inline
std::chrono::time_point<std::chrono::system_clock> gf_time()
{
    return std::chrono::system_clock::now();
}
inline
void sleep(int nsecs)
{
    std::this_thread::sleep_for(std::chrono::seconds(nsecs));
}
int main(int argc, char *argv[])
{
    std::thread thr1, thr2;
    rwlock.lock_shared();   /* parent read locks entire file */
    std::cout << gf_time() << ": parent has read lock" << std::endl;
    thr1 = std::thread(&thread1);
    thr2 = std::thread(&thread2);
	/* 4parent */
    sleep(5);
    rwlock.unlock_shared();
    std::cout << gf_time() << ": parent releases read lock" << std::endl;
    thr1.join();
    thr2.join();
    return 0;
}
void thread1()
{
    sleep(1);
    std::cout << gf_time() << ": first child tries to obtain write lock" << std::endl;
    rwlock.lock();  /* this should block */
    std::cout << gf_time() << ": first child obtains write lock" << std::endl;
    sleep(2);
    rwlock.unlock();
    std::cout << gf_time() << ": first child releases write lock" << std::endl;
}
void thread2()
{
    /* 4second child */
    sleep(3);
    std::cout << gf_time() << ": second child tries to obtain read lock" << std::endl;
    rwlock.lock_shared();
    std::cout << gf_time() << ": second child obtains read lock" << std::endl;
    sleep(4);
    rwlock.unlock_shared();
    std::cout << gf_time() << ": second child releases read lock" << std::endl;
}

该示例代码中,包括两个读者线程(main和thread2)和一个写者线程(thread1),编译运转咱们的程序,咱们可以看到程序的输出如下(考虑到cpu调度的状况,每次结果不一定彻底相同):

11/27/21 14:00:16: parent has read lock
11/27/21 14:00:17: first child tries to obtain write lock
11/27/21 14:00:19: second child tries to obtain read lock
11/27/21 14:00:19: second child obtains read lock
11/27/21 14:00:21: parent releases read lock
11/27/21 14:00:23: second child releases read lock
11/27/21 14:00:23: first child obtains write lock
11/27/21 14:00:25: first child releases write lock

为了更简单的看清楚程序运转的进程,咱们给出时序图(考虑到cpu调度的状况,这仅仅典型时序之一,但不妨碍咱们剖析读写锁的特性):

C++STL shared_mutex实现与分析

  • 1~2:main线程调用lock_shared获取读锁,因为没有其他线程占用读写锁,获取读锁成功。
  • 3~5:main线程分别创立两个子线程thread1和thread2,main线程创立子线程成功后,就sleep 5秒。
  • 6, 8:thread1线程sleep 1秒后,调用lock获取写锁,但这时候因为main线程持有着读锁,所以thread1线程的lock函数block了,rwlock的w_wait为1。
  • 7, 9, 10, 11:thread2线程sleep 3秒后,调用lock获取读锁,这时候因为main线程持有着读锁,所以thread2线程也成功持有了读锁,rwlock的r_active为2,thread2线程紧接着sleep 4秒。
  • 12~13:main线程从sleep 5秒后唤醒,调用unlock_shared开释读锁,r_active减1,因为thread2还持有读锁,所以rwlock的r_active为1。
  • 14~15:thread2线程从sleep 4秒后唤醒,调用unlock_shared开释读锁,r_active减1,这时r_active变成0,这时会进一步查看是否有写线程等候,发现w_wait为1,这是因为这时thread1线程在step 8堵塞在lock函数上,这时,会告诉write条件变量唤醒thread1线程。
  • 16~19:thread1线程从write条件变量的wait中回来,然后查看发现没有任何其他线程占用读写锁,于是成功持有了写锁,rwlock的w_active为1。然后thread1线程sleep 2秒,调用unlock开释写锁,这时,unlock函数完结里,首要查看是否有读线程等候,假如没有读线程等候,才会再查看是否有写线程等候,这便是”读者优先“战略的体现之一。

可以看到,咱们目前完结的shared_mutex类是”读者优先“战略的。


shared_mutex写者优先

接下来,咱们介绍如何将现有完结的shared_mutex类改写成”写者优先“战略的。完好工程代码: github gitee

1. 调整lock_shared逻辑

为完结写线程优先,当有正在等候的写线程(w_wait>0)时,新的读线程的恳求有必要被堵塞,而不仅仅是在有活动的写线程时(咱们已完结的方法)。

void shared_mutex::lock_shared()
{
    std::unique_lock<std::mutex> lock(mutex);
    if (w_active || w_wait > 0) {			// <<<<<< 修正逻辑支撑写优先
        r_wait++;
        while (w_active || w_wait > 0) {	// <<<<<< 修正逻辑支撑写优先
            read.wait(lock);
        }
        r_wait--;
    }
    r_active++;
}

2. 调整try_lock_shared逻辑

同样,该函数有必要也被修正以完结写线程优先,当有一个写线程活动时,或当一个写线程正在等候时,都会回来false。

bool shared_mutex::try_lock_shared()
{
    std::lock_guard<std::mutex> lock(mutex);
    if (w_active || w_wait > 0) {			// <<<<<< 修正逻辑支撑写优先
        return false;
    } else {
        r_active++;
        return true;
    }
}

3. 调整unlock逻辑

要完结写线程优先,只需倒置两个if测试的先后顺序,即唤醒一个等候的写线程(假如有的话),然后再寻觅等候的读线程。

void shared_mutex::unlock()
{
    std::unique_lock<std::mutex> lock(mutex);
    w_active = 0;
    if (w_wait > 0) {					// <<<<<< 修正逻辑支撑写优先
        write.notify_one();
    } else if (r_wait > 0) {			// <<<<<< 修正逻辑支撑写优先
        read.notify_all();
    }
}

至此,咱们就完结了shared_mutex从读者优先战略,转变成写者优先。

咱们可以用写者优先战略的shared_mutex类,再次编译运转之前的示例代码,查看打印输出内容:

11/27/21 14:00:46: parent has read lock
11/27/21 14:00:47: first child tries to obtain write lock
11/27/21 14:00:49: second child tries to obtain read lock
11/27/21 14:00:51: parent releases read lock
11/27/21 14:00:51: first child obtains write lock
11/27/21 14:00:53: first child releases write lock
11/27/21 14:00:53: second child obtains read lock
11/27/21 14:00:57: second child releases read lock

你有发现什么不同吗?仍是老规矩,咱们给出时序图:

C++STL shared_mutex实现与分析

  • 1~2:main线程调用lock_shared获取读锁,因为没有其他线程占用读写锁,获取读锁成功。
  • 3~5:main线程分别创立两个子线程thread1和thread2,main线程创立子线程成功后,就sleep 5秒。
  • 6, 8:thread1线程sleep 1秒后,调用lock获取写锁,但这时候因为main线程持有着读锁,所以thread1线程的lock函数block了,rwlock的w_wait为1。
  • 7, 9:thread2线程sleep 3秒后,调用lock获取读锁,这时候虽然main线程持有着读锁,但thread1线程现已等候着写操作(w_wait为1),所以thread2线程的lock_shared函数也block了,rwlock的r_wait为1。
  • 10~11:main线程从sleep 5秒后唤醒,调用unlock_shared开释读锁,r_active减1,这时r_active为0,因为有写线程等候(thread1线程,w_wait为1),所以优先告诉write条件变量唤醒thread1线程。
  • 12, 13, 14, 15:thread1线程从write条件变量的wait中回来,然后查看发现没有任何其他线程占用读写锁,于是成功持有了写锁(对应step 8),rwlock的w_active为1。然后thread1线程sleep 2秒,调用unlock开释写锁,这时,unlock函数完结里,首要查看是否有写线程等候,假如没有写线程等候,才会再查看是否有读线程等候,这便是”写者优先“战略的体现之一。因为这时候,thread2线程还wait在read条件变量(r_wait为1),所以会告诉read条件变量唤醒thread2线程。
  • 16~19:thread2线程从read条件变量的wait中回来,然后查看发现没有任何其他线程占用读写锁,于是成功持有了读锁(对应step 9),rwlock的r_active+1。然后thread2线程sleep 2秒,调用unlock_shared开释读锁。

可以看到,咱们改写完结的shared_mutex类是”写者优先“战略的。


题外话:

1. 什么是读者优先?

即使写者发出了恳求写的信号,可是只需还有读者在读取内容,就还答应其他读者持续读取内容,直到一切读者结束读取,才真正开端写

  • 有读者在读后面来的读者可以直接进入临界区,而现已在等候的写者持续等候直到没有任何一个读者时。
  • 读者之间不互斥,写者之间互斥,只能一个写,可以多个读,
  • 读者写者之间互斥,有写者写则不能有读者读
  • 假如在读拜访十分频频的场合,有可能形成写进程一向无法拜访文件的局面

2. 什么是写者优先?

假如有写者请求写文件,在请求之前现已开端读取文件的可以持续读取,可是假如再有读者请求读取文件,则不可以读取,只要在一切的写者写完之后才可以读取

  • 写者线程的优先级高于读者线程。
  • 当有写者到来时应该堵塞读者线程的行列。
  • 当有一个写者正在写时或在堵塞行列时应当堵塞读者进程的读操作,直到一切写者进程完结写操作时铺开读者进程。
  • 当没有写者进程时读者进程应该可以同时读取文件。

3. C++17规范中的shared_mutex到底是读优先仍是写优先?

据我所知,C++17规范中并没有限制shared_mutex完结战略是读优先仍是写优先,而是由编译器厂商决议,即由完结界说的。这一点很像POSIX的读写锁接口(pthread_rwlock_*相关接口)。我随便找了两个版别的g++做了实验,结果表明:

  • gcc version 9.3.0的完结中,shared_mutex是读优先的
  • gcc version 10.2.0的完结中,shared_mutex是写优先的

4. 参考引证

  • UNIX网络编程 卷2:进程间通讯 第2版(UNIX Network Programming Volume 2: Interprocess Communications, Second Edition)
  • POSIX多线程程序设计(Programming With POSIX Threads)
  • 读者写者问题(读者优先 写者优先 读写公平):www.cnblogs.com/wwqdata/p/1…