条款16:保证 const 成员函数的线程安全性

ZaynPei Lv6

mutable关键字

mutable(意为“可变的”)是一个存储说明符关键字,它的唯一目的就是“突破 const 限制”。

我们知道,一个被 const 修饰的成员函数(例如 void myFunction() const;)向调用者承诺:“这个函数不会修改对象的任何成员变量。”. 然而, 因为 mutable 用于修饰类的非静态数据成员, 一旦一个成员变量被声明为 mutable,那么即使在一个 const 成员函数中,这个变量也可以被修改。

也就是说, mutable 是 const 这个规则的一个“例外条款”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyClass {
private:
int regularValue;
mutable int mutableValue; // 被 mutable 修饰

public:
MyClass(int v1, int v2) : regularValue(v1), mutableValue(v2) {}

void regularFunc() {
regularValue = 100; // OK:普通函数可以修改普通成员
mutableValue = 200; // OK:普通函数当然也可以修改 mutable 成员
}

void constFunc() const {
// regularValue = 100; // 编译错误!const 函数不能修改非 mutable 成员
mutableValue = 200; // 正确!mutable 成员可以在 const 函数中被修改
}
};
#### 为什么需要 mutable?

你可能会问:const 函数的意义不就是“不修改”吗?允许修改岂不是违背了初衷?这里引出了 C++ 中关于 “const” 的两个重要概念:

  • 按位 Const (Bitwise Constness):这是编译器默认的实现方式。只要一个成员函数没有修改对象内存布局中的任何一个 bit(位),它就是 const 的。
  • 逻辑 Const (Logical Constness):这是开发者和调用者所期望的。只要一个成员函数没有修改对象的“外部可见的逻辑状态”,它就应该是 const 的。

在大多数情况下,两者是一致的。但有时,为了维持“逻辑上的不变”,我们可能需要修改一些“内部实现上的细节”。mutable 就是用来实现“逻辑 Const”的工具。

mutable用例

用例 1:缓存 (Caching) / 延迟计算 (Memoization) 假设你有一个类,它有个函数需要执行非常昂贵的计算,但这个计算结果在对象生命周期内是固定的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ExpensiveCalculator {
private:
SomeInputData data;

// 用于缓存计算结果
mutable double cachedResult;
mutable bool cacheValid = false; // 跟踪缓存是否有效

double expensiveCalculation() const {
// ... 执行非常复杂和耗时的计算 ...
return 42.0;
}

public:
double getValue() const { // 这是一个 const 函数,因为它逻辑上不改变计算器的状态
if (!cacheValid) {
// 这是修改操作,但它只发生在 const 函数内部
cachedResult = expensiveCalculation(); // 必须是 mutable
cacheValid = true; // 必须是 mutable
}
return cachedResult;
}
};
getValue() 承诺自己是 const 的,因为它返回的值(逻辑状态)始终是相同的。但是为了提高性能,它在内部使用了 mutable 变量来缓存结果。从外部调用者看来,这个对象没有发生任何逻辑上的改变, 尽管内部的确发生了一些无关紧要的优化类型的变量改变.

用例 2:用于 Mutex 假设我们有一个类,它的大多数成员函数都是 const(因为它们只是读取数据),但我们希望这些读取操作在多线程环境下是安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ThreadSafeReader {
private:
std::string sharedData = "Data";
mutable std::mutex dataMutex; // 关键!Mutex 必须是 mutable 的

public:
std::string getData() const { // 这是一个 const 成员函数

// 我们需要锁,因为可能有其他线程(通过非 const 函数)正在写入 sharedData
// 但是 lock() 是一个“非 const”操作(它会修改 mutex 内部的状态)
// 为了能在 const 函数 getData() 内部调用 dataMutex.lock(),
// dataMutex 必须被声明为 mutable。

std::lock_guard<std::mutex> guard(dataMutex); // 这在编译上是合法的

return sharedData;
}

void setData(const std::string& s) {
std::lock_guard<std::mutex> guard(dataMutex);
sharedData = s;
}
};
在这个例子中,getData() 从逻辑上看是 const 的(它只是返回数据,不改变类的逻辑状态)。但是为了保证线程安全,它必须对内部的 dataMutex 进行 lock() 操作。lock() 操作会修改 mutex 对象本身的状态。因此,为了让编译器允许 const 函数修改 mutex 成员,这个 mutex 必须被声明为 mutable。

这是 mutable 最常见、最标准的用途之一:允许 const 成员函数获取锁以实现线程安全。

mutex 互斥量

mutex 不是关键字,它是 C++11 在 头文件中提供的一个类,全称为 Mutual Exclusion(互斥)。

它是多线程编程中的一种同步原语(Synchronization Primitive), 其核心作用是保护共享资源,以防止多个线程同时访问和修改该资源时引发的竞态条件 (Race Condition)。

为什么需要 mutex?

在多线程程序中,如果多个线程同时尝试修改同一个变量(例如一个全局计数器 counter),就会发生问题。

一个简单的 counter++ 操作在底层汇编中通常至少包含三个步骤: - 读取 (Read):将 counter 的当前值从内存加载到 CPU 寄存器。 - 修改 (Modify):在寄存器中将该值加 1。 - 写回 (Write):将寄存器中的新值写回到内存中的 counter 位置。

竞态条件示例:假设 counter 当前为 10, 首先线程 A 读取 counter得到 10(此时发生上下文切换), 线程 B 读取 counter (也得到 10); 接着线程 B 修改寄存器变为 11并写回 11 到 counter; 然后再切回线程 A(不同线程执行时间有差异), 线程 A 仍然拿着旧值 10修改寄存器变为 11并写回 11 到 counter。

最后的结果是两个线程都执行了 ++ 操作,但 counter 的最终结果是 11,而不是预期的 12。数据损坏了。

Mutex 如何工作?

mutex 通过提供两个核心操作 lock() 和 unlock() 来解决这个问题。

我们可以把共享资源(如 counter)想象成一个“秘密会议室”,而 mutex 就是挂在门上的“唯一的锁”。

  • 临界区 (Critical Section):那段必须被保护起来、不允许并发执行的代码(如 counter++)被称为“临界区”。
  • lock():当一个线程想要进入临界区时,它必须先尝试获取锁,即调用 my_mutex.lock()。如果锁可用,该线程获取锁,然后进入临界区; 如果锁已被其他线程持有,该线程将阻塞 (Block),直到那个线程释放锁。
  • unlock():当线程完成临界区的工作后,它必须释放锁,即调用 my_mutex.unlock(),以便其他正在等待的线程可以获取它。

通过这种机制,mutex 确保了在任何时刻,只有一个线程能够进入临界区,从而保证了操作的原子性 (Atomicity)。

现代 C++ 中的安全用法:RAII 与 std::lock_guard

手动调用 lock() 和 unlock() 是非常危险的。如果在 lock() 之后、unlock() 之前,代码抛出了一个异常,那么 unlock() 将永远不会被调用,这个锁将永久锁定,所有其他等待该锁的线程将无限期阻塞(称为死锁 Deadlock)。

为了解决这个问题,C++ 提供了 RAII(资源获取即初始化)模式的封装类:std::lock_guard。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <mutex>
#include <thread>

int counter = 0;
std::mutex mtx; // 全局互斥量,用于保护 counter

void thread_task() {
for (int i = 0; i < 10000; ++i) {
// 安全的做法:使用 lock_guard
// 1. 当 guard 对象被创建时(构造函数),它自动调用 mtx.lock()
std::lock_guard<std::mutex> guard(mtx);

counter++; // 这里是临界区,现在是线程安全的

// 2. 当 guard 离开作用域时(在 '}' 处),其析构函数被自动调用,
// 析构函数会自动调用 mtx.unlock()。
// 即使 counter++ 抛出异常(虽然 int++ 不会),析构函数也会被调用,保证解锁。
}
}
std::lock_guard 利用了 C++ 的构造函数和析构函数机制。在构造时自动加锁,在析构时(无论函数是正常返回还是因异常退出)自动解锁,从而完美地保证了锁的释放。

std::atomic

我们之前讨论了 mutex(互斥锁)。mutex 采用阻塞 (Blocking), 是解决多线程数据竞争的一种重量级的同步机制。而 std::atomic 提供了一种非阻塞 (Non-Blocking) 的同步方式。下面我们先了解一下 std::atomic

多线程阻塞这种“挂起”和“唤醒”操作涉及到了操作系统内核态与用户态的切换,这是一个非常昂贵(高延迟)的操作。对于那些需要频繁更新、但冲突并不严重的共享变量(例如一个全局访问计数器),使用 mutex 就像“杀鸡用牛刀”,开销太大了。

std::atomic 是 C++11 引入的一个极其重要的特性,它位于 头文件中。它是 C++ 底层内存模型和原子操作的基石,也是实现无锁编程 (Lock-Free Programming) 的核心工具。

std::atomic 提供了一种能力,使得对某个变量的简单操作(如读、写、增、减、交换等)可以原子性 (Atomically) 地完成。它依赖于 CPU 硬件层面提供的原子指令 (Atomic Instructions)(例如 x86 上的 LOCK CMPXCHG)。这些特殊的 CPU 指令可以在硬件层面保证一个“读-改-写”的操作序列在执行的中途不会被任何其他线程打断(即它是原子的)。

当一个变量被声明为 std::atomic 时,编译器会确保对这个变量的所有操作都转化为这些特殊的原子 CPU 指令,而不是我们之前担心的普通的“读、改、写”三部曲。

上述示例中采用std::atomic的同款代码如下:

1
2
3
4
5
6
7
8
9
10
#include <atomic>
std::atomic<int> atomic_counter(0); // 将 counter 封装为 atomic 类型

void task() {
atomic_counter++; // 这一行代码会被编译成一条单一的、原子的 CPU 指令
// (例如在 x86 上是 "LOCK XADD")

// 或者使用等效的 fetch_add,语义更清晰:
atomic_counter.fetch_add(1); // 以原子的方式“取回当前值并加1”
}
在 atomic 版本中,当多个线程同时执行 atomic_counter.fetch_add(1) 时,CPU 硬件会保证这些指令依次排队执行,一个完成了下一个才能开始(在极小的 CPU 指令时间尺度上),它们不会像普通 ++ 那样发生“读-改-写”步骤的交错。

最重要的是,这个过程完全在用户态完成,没有线程会被操作系统挂起(阻塞)。如果发生冲突,线程通常只会进行几次“自旋 (Spin)”(即 CPU 空转几个周期再试一次),这比内核调度的开销小得多。