C++读写锁

Share on:
700 Words | Read in about 3 Min | View times

Overview

读写锁是并发编程中的一项重要的技术,相较于互斥锁(要么锁住要么不加锁),读写锁可以在更细的粒度上提高并发性能。现代C++提供了std::shared_mutexstd::shared_timed_mutex两种共享互斥量,以及用来管理这类共享互斥量的std::shared_lock。本节内容对现代C++中的读写锁进行详细介绍。

本系列文章将包括以下领域:

本章其他内容请见 《现代C++》

什么是读写锁?

提到读写锁之前,我们先简单了解一下与之相对的互斥锁。互斥锁,是只同一时刻只能有一个线程获得锁,其余尝试加锁的线程都处于阻塞状态。

而相比于互斥锁的两种状态(锁住和未加锁),读写锁可以有三种状态,即读模式加锁、写模式加锁和未加锁。只有一个线程可以处于写模式加锁状态,但是可以有多个线程同时处于读模式加锁状态。

当处于写模式加锁状态时是独占的,而处于读模式加锁状态时是共享的,所以读写锁也称为独占-共享锁。读写锁处于写模式加锁状态时,在解锁之前,任何尝试对其加锁的线程都会阻塞,此时加读锁或者加写锁都会阻塞;读写锁处于读模式加锁状态时,在解锁之前,所有尝试读模式加锁的线程都可以获得访问权,所有尝试写模式加锁的线程都会阻塞。可以用下表来表示它们之间的关系:

锁状态 加读锁 加写锁
写模式加锁状态 失败 失败
读模式加锁状态 成功 失败
未加锁状态 成功 成功

现代C++中的读写锁

现代C++提供了std::shared_mutex(C++17)实现读写锁,std::shared_timed_mutex(C++14)实现带超时逻辑的读写锁,std::shared_lock(C++14)用来安全管理这些读写锁。

std::shared_mutex

从C++17开始提供了std::shared_mutex用来实现基本的读写锁。这个类的本质是一个可共享互斥量,与std::mutex都提供类似的同步互斥语义,可以保护共享数据不被多个线程同时修改。std::shared_mutex除了提供排他性操作外,还提供共享性操作。排他性操作可以使一个线程独占该互斥量,而共享性操作可以使多个线程共享该互斥量。

若一个线程已获取排他性锁,即通过lock()try_lock()上锁,则其他线程不能获取该锁,包括共享锁。

当任何线程都未获取排他性锁时,共享锁能被多个线程获取,即通过lock_shared()try_lock_shared()上锁。

在一个线程内,同一时刻只能获取一个锁(共享锁或排他锁)。

std::shared_mutex定义在标准库头文件<shared_mutex>中,声明原型为:

1class shared_mutex;

std::shared_mutex只有默认构造函数,没有拷贝构造函数,也没有赋值操作。

std::shared_mutex的成员函数如下:

  • 排他性锁

    • lock

    排他性锁定互斥量,相当于写模式上锁,若锁定失败则阻塞。已经获得互斥量所有权(不管是排他锁还是共享锁)的线程调用lock(),会引发未定义行为错误。一般不直接使用std::shared_mutex::lock,而是使用std::unique_lockstd::lock_guard来进行互斥量管理。

    • try_lock

    尝试排他性锁定互斥量,相当于写模式上锁,若锁定失败不阻塞,直接返回布尔结果。

    • unlock

    解锁排他性互斥量。解锁的前提必须是上过排他锁,否则会引发未定义行为错误。一般也不直接调用std::shared_mutex::unlock,而是用std::unique_lockstd::lock_guard来进行互斥量管理。

  • 共享性锁

    • lock_shared

    共享性锁定互斥量,相当于读模式上锁,若锁定失败则阻塞。已经获得互斥量所有权(不管是排他锁还是共享锁)的线程调用lock_shared(),会引发未定义行为错误。一般不直接使用std::shared_mutex::lock_shared,而是使用std::shared_lock来进行互斥量管理。若超过实现定义最大数量的共享所有者已经以读模式对同一互斥量上锁,则lock_shared()会阻塞,直到共享所有者的数量减少,所有者的最大数量保证至少为10000。

    • try_lock_shared

    尝试共享性锁定互斥量,相当于读模式上锁,若锁定失败不阻塞,直接返回布尔结果。

    • unlock_shared

    解锁共享性互斥量。解锁的前提必须是上过共享锁,否则会引发未定义行为错误。一般也不直接调用std::unlock_shared,而是用std::shared_lock来进行互斥量管理。

std::shared_timed_mutex

从C++14开始提供了std::shared_timed_mutex来实现带有延时锁定、定时锁定等效果的读写锁。这个类的本质也是一个可共享互斥量,与std::timed_mutex一样提供类似的同步互斥语义,可以保护共享数据不被多个线程同时修改。可以理解为在std::shared_mutex的基础上加上std::timed_mutex的特性。

std::shared_timed_mutex定义在标准库头文件<shared_mutex>中,声明原型为:

1class shared_timed_mutex;

std::shared_timed_mutex只有默认构造函数,没有拷贝构造函数,也没有赋值操作。

std::shared_timed_mutex的成员函数如下:

  • 排他性锁

    • lock

    排他性锁定互斥量,相当于写模式上锁,若锁定失败则阻塞。已经获得互斥量所有权(不管是排他锁还是共享锁)的线程调用lock(),会引发未定义行为错误。一般不直接使用std::shared_timed_mutex::lock,而是使用std::unique_lockstd::lock_guard来进行互斥量管理。

    • try_lock

    尝试排他性锁定互斥量,相当于写模式上锁,若锁定失败不阻塞,直接返回布尔结果。

    • try_lock_for

    尝试排他性锁定互斥量,相当于写模式上锁,若在指定时间长度内锁定失败不阻塞,直接返回布尔结果。

    • try_lock_until

    尝试排他性锁定互斥量,相当于写模式上锁,若到达指定时间点锁定失败不阻塞,直接返回布尔结果。

    • unlock

    解锁排他性互斥量。解锁的前提必须是上过排他锁,否则会引发未定义行为错误。一般也不直接调用std::shared_timed_mutex::unlock,而是用std::unique_lockstd::lock_guard来进行互斥量管理。

  • 共享性锁

    • lock_shared

    共享性锁定互斥量,相当于读模式上锁,若锁定失败则阻塞。已经获得互斥量所有权(不管是排他锁还是共享锁)的线程调用lock_shared(),会引发未定义行为错误。一般不直接使用std::shared_shared_mutex::lock_shared,而是使用std::shared_lock来进行互斥量管理。若超过实现定义最大数量的共享所有者已经以读模式对同一互斥量上锁,则lock_shared()会阻塞,直到共享所有者的数量减少,所有者的最大数量保证至少为10000。

    • try_lock_shared

    尝试共享性锁定互斥量,相当于读模式上锁,若锁定失败不阻塞,直接返回布尔结果。

    • try_lock_shared_for

    尝试共享性锁定互斥量,相当于读模式上锁,若在指定时间长度内锁定失败不阻塞,直接返回布尔结果。

    • try_lock_shared_until

    尝试共享性锁定互斥量,相当于读模式上锁,若到达指定时间点锁定失败不阻塞,直接返回布尔结果。

    • unlock_shared

    解锁共享性互斥量。解锁的前提必须是上过共享锁,否则会引发未定义行为错误。一般也不直接调用std::unlock_shared,而是用std::shared_lock来进行互斥量管理。

std::shared_lock

为了使std::shared_mutexstd::shared_timed_mutex使用起来更加方便安全,从C++14开始,标准库提供了std::shared_lock来管理这两种共享互斥量,就如同使用std::unique_lockstd::lock_guard来管理独占互斥量std::mutexstd::timed_mutex一样。

std::shared_lock是一个模板类,定义在标准库头文件<shared_mutex>中,声明原型为:

1template<class Mutex>
2class shared_lock;

std::shared_lock是通用的共享互斥量包装器,支持延迟锁定、定时锁定和锁所有权的转移。

std::shared_lock支持移动语义,但不支持复制语义,即提供了移动构造函数和移动赋值操作符,而不提供拷贝构造函数和拷贝赋值操作符。

std::shared_lock关联的共享互斥量只要支持可锁定(Lockable)要求,std::shared_lock就支持可锁定要求;关联的共享互斥量只要支持可共享定时锁定(SharedTimeLocable)要求,std::shared_lock就支持可共享定时锁定。

可锁定要求,Lockable,指可锁定类型的对象m提供m.try_lock()表达式。

  • m.try_lock(),尝试为当前执行代理(线程、进程、任务)获取锁而不阻塞,若抛异常则不获得锁。成功获得锁返回true,失败返回false

可共享定时锁定要求,SharedTimeLockable,指可共享定时锁定类型的对象m提供m.try_lock_for(rel_time)m.try_lock_until(abs_time)表达式。

  • m.try_lock_for(rel_time),阻塞提供的时长rel_time,或者直到m获得锁。成功获得锁返回true,失败返回false
  • m.try_lock_until(abs_time),阻塞直指定时间点abs_time,或者直到m获得锁。成功获得锁返回true,失败返回false

std::shared_lock会以共享模式上锁关联的共享互斥量,而std::unique_lock则是以排他性模式上锁。所以经常由这二者搭配实现读写锁。

std::shared_lock的成员函数如下:

  • 共享性锁

    • lock

    以共享模式锁定互斥量,若锁定失败则阻塞。等价于调用mutex()->lock_shared()

    • try_lock

    以共享模式锁定互斥量,若锁定失败不阻塞,直接返回布尔结果。等价于调用mutex()->try_lock_shared()

    若无关联互斥量,或互斥量已上锁,则抛出std::system_error异常。

    • try_lock_for

    以共享模式锁定互斥量,若在指定时间长度内锁定失败不阻塞,直接返回布尔结果。等价于调用mutex()->try_lock_shared_for(timeout_duration)

    若无关联互斥量,则抛出错误码为std::errc::operation_not_permittedstd::system_error异常;若互斥量已上锁,则抛出错误码为std::errc::resource_deadlock_would_occurstd::system_error异常。

    若互斥量不支持可共享定时锁定要求,则引发未定义行为错误。

    • try_lock_until

    以共享模式锁定互斥量,若到达指定时间点锁定失败不阻塞,直接返回布尔结果。等价于调用mutex()->try_lock_shared_until(timeout_time)

    若无关联互斥量,则抛出错误码为std::errc::operation_not_permittedstd::system_error异常;若互斥量已上锁,则抛出错误码为std::errc::resource_deadlock_would_occurstd::system_error异常。

    若互斥量不支持可共享定时锁定要求,则引发未定义行为错误。

    • unlock

    解锁共享性互斥量。解锁的前提必须是上过共享锁,否则会引发未定义行为错误。等价于调用mutex()->unlock_shared()

    若无关联互斥,则抛出错误码为std::errc::operation_not_permittedstd::system_error异常。

  • 修改器

    • swap

    与另一std::shared_lock交换数据成员。

    • release

    解除关联的互斥量,但不解锁。返回关联互斥量的指针,若无关联互斥量则返回空指针nullptr

  • 观察器

    • mutex

    返回关联的互斥量指针,若无关联互斥量则返回空指针nullptr

    • owns_lock

    检查*this是否拥有已上锁的互斥量。等价于调用operator bool

    • operator bool

    等价于调用owns_lock()

使用示例

case1: 测试排他性锁

 1#include <iostream>
 2#include <thread>
 3#include <shared_mutex>
 4#include <chrono>
 5
 6int num = 0;
 7std::shared_mutex mtx;
 8
 9void incr() {
10    for (int i = 0; i < 3; ++i) {
11        mtx.lock();
12        std::cout << std::this_thread::get_id() << ": " << ++num << std::endl;
13        mtx.unlock();
14        std::this_thread::sleep_for(std::chrono::seconds(1));
15    }
16}
17
18int main() {
19    std::thread t1(incr);
20    std::thread t2(incr);
21    t1.join();
22    t2.join();
23    return 0;
24}

运行结果:

1140170984482560: 1
2140170976089856: 2
3140170984482560: 3
4140170976089856: 4
5140170984482560: 5
6140170976089856: 6

从运行结果表明两个线程交替输出,即交替获得排他性锁,同一时间只能由一个线程修改num

case2: 使用shared_lock和unique_lock管理共享互斥量

std::shared_lock经常和std::unique_lock打配合。只有在没有写锁的情况下,std::shared_lock才能获得锁;只有在没有读锁的情况下,std::unique_lock才能获得写锁。

 1#include <iostream>
 2#include <shared_mutex>
 3#include <thread>
 4#include <chrono>
 5
 6void test_shared_lock() {
 7    int num = 0;
 8    std::shared_mutex mtx;
 9
10    auto reading = [&]() {
11        for (int i = 0; i < 3; ++i) {
12            std::shared_lock<std::shared_mutex> lck(mtx);
13            std::cout << std::this_thread::get_id() << " reading: " << num << std::endl;
14        }
15    };
16
17    auto writing = [&]() {
18        for (int i = 0; i < 3; ++i) {
19            std::unique_lock<std::shared_mutex> lck(mtx);
20            std::cout << std::this_thread::get_id() << " writing: " << ++num << std::endl;
21        }
22    };
23
24    std::thread r1(reading);
25    std::thread w1(writing);
26    std::thread r2(reading);
27    std::thread w2(writing);
28
29    r1.join();
30    w1.join();
31    r2.join();
32    w2.join();
33}
34
35void test_shared_lock_for() {
36    int num = 0;
37    std::shared_timed_mutex mtx;
38
39    auto reading = [&]() {
40        for (int i = 0; i < 3; ++i) {
41            std::shared_lock<std::shared_timed_mutex> lck(mtx, std::chrono::milliseconds(10));
42            std::cout << std::this_thread::get_id() << " reading: " << num << std::endl;
43        }
44    };
45
46    auto writing = [&]() {
47        for (int i = 0; i < 3; ++i) {
48            std::unique_lock<std::shared_timed_mutex> lck(mtx, std::chrono::milliseconds(10));
49            std::cout << std::this_thread::get_id() << " writing: " << ++num << std::endl;
50        }
51    };
52
53    std::thread r1(reading);
54    std::thread w1(writing);
55    std::thread r2(reading);
56    std::thread w2(writing);
57
58    r1.join();
59    w1.join();
60    r2.join();
61    w2.join();
62}
63
64void test_shared_lock_until() {
65    int num = 0;
66    std::shared_time_mutex mtx;
67
68    auto reading = [&]() {
69        for (int i = 0; i < 3; ++i) {
70            std::shared_lock<std::shared_timed_mutex> lck(mtx, std::chrono::time_point<std::chrono::high_resolution_clock>(std::chrono::milliseconds(10)));
71            std::cout << std::this_thread::get_id() << " reading: " << num << std::endl;
72        }
73    };
74
75    auto writing = [&]() {
76        for (int i = 0; i < 3; ++i) {
77            std::unique_lock<std::shared_timed_mutex> lck(mtx, std::chrono::time_point<std::chrono::high_resolution_clock>(std::chrono::milliseconds(10)));
78            std::cout << std::this_thread::get_id() << " writing: " << ++num << std::endl;
79        }
80    };
81
82    std::thread r1(reading);
83    std::thread w1(writing);
84    std::thread r2(reading);
85    std::thread w2(writing);
86
87    r1.join();
88    w1.join();
89    r2.join();
90    w2.join();
91}
92
93int main() {
94    test_shared_lock();
95    //test_shared_lock_for();
96    //test_shared_lock_until();
97    return 0;
98}

可能的输出结果:

 1140044434528000 reading: 0
 2140044426135296 writing: 1
 3140044434528000 reading: 1
 4140044434528000140044417742592 reading:  reading: 11
 5
 6140044409349888 writing: 2
 7140044417742592 reading: 2
 8140044409349888 writing: 3
 9140044417742592 reading: 3
10140044409349888 writing: 4
11140044426135296 writing: 5
12140044426135296 writing: 6

从结果可以看出,reading的输出结果可能混在一起,表明了同一时间可以有多个读线程同时获得读锁并输出了读取结果,而writing的输出表明同一时间只能一个写线程独占锁。

总结

  • 读写锁又称为共享-独占锁。写锁是独占的,其余加锁行为都会阻塞;读锁是共享的,多个线程可同时获得读锁,但加写锁会阻塞。

  • 现代C++提供了std::shared_mutexstd::shared_timed_mutex两种共享互斥量,又提供了std::shared_lockstd::unique_lock共同管理这类互斥量来实现读写锁。

  • std::shared_lock只是对共享互斥量的一种包装器,提供了更加安全方便的调用操作。

现代C++的并发编程中还有更多锁机制和线程同步技术,本文内容仅是这一大话题的一小模块。更多C++并发编程的内容我将在后续专门的章节全面展开。

Prev Post: 『C++14模板的改进』
Next Post: 『C++14 exchange』