多线程之读写锁

1. 读写锁

1.1 读写锁模型

在多线程编程中,普通的 std::mutex独占锁。同一时间,只允许一个线程访问数据。然而,对于读多写少的数据(例如配置文件、缓存数据),这是低效的,因为读取操作本身是线程安全的,只需要防止在读取的过程中被写入打断即可。

std::shared_timed_mutexstd::shared_lock 是 C++14 引入的一对组合,用于实现读写锁机制。读写锁机制允许通过区分读操作写操作来提高程序的并发性能。其核心思想是:读操作是共享的,写操作是独占的。

  • 独占模式
    • 对应 写操作
    • 如果一个线程获得了独占锁(写锁),其他任何线程都无法获得共享锁(读)或独占锁(写)。
    • 配合类:通常使用 std::unique_lock
  • 共享模式
    • 对应 读操作
    • 如果一个线程获得了共享锁(读锁),其他线程可以同时获得共享锁来读取数据。
    • 但是,其他线程无法获得独占锁(写),直到所有共享锁被释放。
    • 配合类:必须使用 std::shared_lock
操作类型 线程 A (读) 线程 B (读) 线程 C (写)
场景 1 ✅ 可以进入 ✅ 可以进入 ❌ 必须等待
场景 2 ⛔ 等待 ⛔ 等待 ✅ 正在写入

1.2 类详解

  1. std::shared_timed_mutex

    这是一个互斥量类,类似于 std::mutex,位于 <shared_mutex> 头文件中。支持共享(读)和独占(写)两种锁定模式,带有超时功能。

    std::shared_timed_mutex 在线 API 文档

    主要成员函数:

    • 构造函数:构造互斥量,调用后互斥量处于未锁定状态。

      1
      shared_timed_mutex();
    • 独占锁定

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      // 锁住互斥量。如果其他线程已锁住互斥量,则对 lock 的调用将阻塞执行,直到获得锁。
      void lock();
      // 尝试锁定互斥量。立即返回。成功获取锁时返回 true,否则返回 false。
      bool try_lock();
      // 试锁定互斥量。阻塞直到指定的持续时间 timeout_duration 已过去(超时)或锁被获取(拥有互斥量),
      // 以先发生者为准。成功获取锁时返回 true,否则返回 false。
      template< class Rep, class Period >
      bool try_lock_for( const std::chrono::duration<Rep, Period>& timeout_duration );
      // 尝试锁定互斥量。阻塞直到达到指定的时间点 timeout_time(超时)或成功获取锁(拥有互斥量),
      // 以先发生者为准。成功获取锁时返回 true,否则返回 false。
      template< class Clock, class Duration >
      bool try_lock_until( const std::chrono::time_point<Clock, Duration>& timeout_time );
      // 解锁互斥量
      void unlock();
    • 共享锁定

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // 获取互斥量的共享所有权。如果另一个线程以独占所有权持有互斥量,则调用 lock_shared 
      // 将会阻塞执行,直到可以获取共享所有权。
      void lock_shared();
      // 尝试以共享模式锁定互斥体。立即返回。如果成功获取锁,则返回true,否则返回false。
      bool try_lock_shared();
      // 尝试以共享模式锁定互斥量。阻塞直到指定的 timeout_duration 已过去或者共享锁被获取,以先发生者为准。
      // 成功获取锁时返回 true,否则返回 false。
      template< class Rep, class Period >
      bool try_lock_shared_for( const std::chrono::duration<Rep,Period>& timeout_duration );
      // 尝试以共享模式锁定互斥量。阻塞直到达到指定的时间点 timeout_time 或获得锁,以先发生者为准。
      // 成功获得锁时返回 true,否则返回 false。
      template< class Clock, class Duration >
      bool try_lock_shared_until( const std::chrono::time_point<Clock,Duration>& timeout_time );
      // 释放调用线程对互斥体的共享所有权。
      void unlock_shared();
  2. std::shared_lock

    这是一个RAII 风格的锁包装器,类似于 std::unique_lock,但它是专门为配合shared_timed_mutex(或 shared_mutex)的共享模式设计的。

    当构造一个 std::shared_lock 对象时,它会自动调用互斥量的 lock_shared();当 std::shared_lock 对象析构时,它会自动调用 unlock_shared()。这意味着如果代码抛出异常,锁也能被正确释放,避免死锁。

    RAII(Resource Acquisition Is Initialization,资源获取即初始化)是 C++ 中的一个核心编程范式,它利用对象的生命周期来管理资源(如内存、文件句柄、互斥锁等),确保资源在使用后被正确释放,从而避免资源泄漏。

    std::shared_lock 在线 API 文档

    构造函数:第一个参数:std::shared_timed_mutex 对象。

    1
    2
    3
    4
    explicit shared_lock( mutex_type& m );
    shared_lock( mutex_type& m, std::defer_lock_t t ) noexcept;
    shared_lock( mutex_type& m, std::try_to_lock_t t );
    shared_lock( mutex_type& m, std::adopt_lock_t t );
    第二个参数参数类型 效果 实参
    defer_lock_t 不获取互斥量所有权 std::defer_lock
    try_to_lock_t 尝试获取互斥量所有权而不阻塞 std::try_to_lock
    adopt_lock_t 假定调用线程已拥有互斥量 std::adopt_lock

2. 读写锁的使用

下面的代码模拟了一个简单的共享数据缓存。我们将看到:

  1. 多个读者线程:使用 std::shared_lock 同时读取数据。
  2. 一个作者线程:使用 std::unique_lock 定期修改数据。
  3. 超时尝试:演示如何尝试带超时的读取。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#include <iostream>
#include <shared_mutex>
#include <thread>
#include <vector>
#include <string>
#include <chrono>
#include <mutex>

// 定义一个线程安全的共享数据类
class SharedData
{
public:
SharedData(std::string initial_data) : data_(initial_data) {}

// 【读操作】
// 使用 std::shared_lock 管理共享锁
// const 方法非常重要,表示不会修改数据
std::string read() const
{
// 构造 shared_lock 时,自动调用 m_mutex_.lock_shared()
// 允许多个线程同时进入这个临界区
std::shared_lock<std::shared_timed_mutex> lock(m_mutex_);

std::cout << "[Reader] 正在读取数据: " << data_ << std::endl;

// 模拟读取耗时
std::this_thread::sleep_for(std::chrono::milliseconds(500));

return data_;
// 离开作用域,lock 析构,自动调用 m_mutex_.unlock_shared()
}

// 【写操作】
// 使用 std::unique_lock 管理独占锁
void update(const std::string& new_data)
{
// unique_lock 会调用 m_mutex_.lock()
// 此时任何其他的读或写操作都会被阻塞
std::unique_lock<std::shared_timed_mutex> lock(m_mutex_);

std::cout << "[Writer] 开始更新数据..." << std::endl;

// 模拟写入耗时
std::this_thread::sleep_for(std::chrono::milliseconds(1000));

data_ = new_data;
std::cout << "[Writer] 数据更新为: " << data_ << std::endl;

// 离开作用域,lock 析构,自动调用 m_mutex_.unlock()
}

// 【尝试带超时的读操作】
// 这展示 shared_timed_mutex 中 "timed" 特性的用法
bool try_read_for(int duration_ms)
{
// 尝试获取共享锁,等待 duration_ms 毫秒
std::shared_lock<std::shared_timed_mutex> lock(m_mutex_, std::defer_lock);

if (lock.try_lock_for(std::chrono::milliseconds(duration_ms)))
{
// 成功获取锁
std::cout << "[TryReader] 在超时前成功读取: " << data_ << std::endl;
return true;
}
else
{
// 超时未获取到锁(可能因为 Writer 正在写入)
std::cout << "[TryReader] 读取失败:等待超时!" << std::endl;
return false;
}
}

private:
// mutable 关键字允许在 const 成员函数中修改它
// 锁的状态改变不算作修改对象的逻辑数据
mutable std::shared_timed_mutex m_mutex_;
std::string data_;
};

int main()
{
SharedData shared_data("初始数据 v1.0");

// 启动 3 个读者线程
std::vector<std::thread> readers;
for (int i = 0; i < 3; ++i)
{
readers.emplace_back([&shared_data, i]()
{
for (int j = 0; j < 2; ++j)
{
shared_data.read();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
}

// 启动 1 个写者线程
std::thread writer([&shared_data]()
{
// 让读者先跑一会儿
std::this_thread::sleep_for(std::chrono::milliseconds(800));
shared_data.update("更新后的数据 v2.0");

std::this_thread::sleep_for(std::chrono::milliseconds(2000));
shared_data.update("最终数据 v3.0");
});

// 启动一个会超时的读线程
std::thread timeout_reader([&shared_data]()
{
// 故意在 Writer 刚要开始工作时尝试读取,大概率会超时
std::this_thread::sleep_for(std::chrono::milliseconds(750));
shared_data.try_read_for(200);
});

// 等待所有线程结束
for (auto& t : readers) t.join();
writer.join();
timeout_reader.join();

return 0;
}

代码运行逻辑解析:

  1. 初始阶段 (0ms - 800ms):
    • 3 个 Reader 线程启动。
    • 因为没有 Writer,它们都可以通过 std::shared_lock 获取共享锁。
    • 现象:会看到多个 [Reader] 正在读取数据几乎同时打印出来。这正是共享锁的魅力:并发读取
  2. 写者介入 (800ms):
    • Writer 线程醒来,调用 update
    • 它尝试获取 unique_lock(独占锁)。
    • 此时,可能还有读者持有锁。Writer 必须等待所有的 shared_lock 释放。
  3. 阻塞阶段:
    • 一旦 Writer 成功获取锁,所有新的 read() 请求都会阻塞。
    • 如果此时 timeout_reader 尝试读取并限制 200ms,它无法获得锁,因此打印 [TryReader] 读取失败:等待超时!。这演示了 std::shared_timed_mutex 的超时控制能力。
  4. 写入阶段:
    • 持有锁 1000ms(模拟慢速写入),期间阻塞所有其他访问。
    • 打印 [Writer] 数据更新为…
  5. 恢复阶段:
    • Writer 完成,锁释放。
    • 被阻塞的读者线程再次苏醒,继续并发读取数据。

总结与建议:

  • 何时使用 std::shared_lock:当你访问共享数据且不打算修改它时。
  • 何时使用 std::unique_lock:当你需要修改共享数据时。
  • 何时使用 std::shared_timed_mutex:当你需要读写分离,并且可能在获取锁时有超时需求(例如在日志系统、异步任务队列中很常见)。

如果不需要超时功能(即普通的 lockunlock),编译器优化的 std::shared_mutex (C++17引入) 在某些平台上性能可能更好。但在 C++14 环境下,std::shared_timed_mutex 是唯一的选择。