条款16:保证 const 成员函数的线程安全性
mutable关键字
mutable(意为“可变的”)是一个存储说明符关键字,它的唯一目的就是“突破 const 限制”。
我们知道,一个被 const 修饰的成员函数(例如 void myFunction() const;)向调用者承诺:“这个函数不会修改对象的任何成员变量。”. 然而, 因为 mutable 用于修饰类的非静态数据成员, 一旦一个成员变量被声明为 mutable,那么即使在一个 const 成员函数中,这个变量也可以被修改。
也就是说, mutable 是 const 这个规则的一个“例外条款”。
1 | class MyClass { |
你可能会问:const 函数的意义不就是“不修改”吗?允许修改岂不是违背了初衷?这里引出了 C++ 中关于 “const” 的两个重要概念:
- 按位 Const (Bitwise Constness):这是编译器默认的实现方式。只要一个成员函数没有修改对象内存布局中的任何一个 bit(位),它就是 const 的。
- 逻辑 Const (Logical Constness):这是开发者和调用者所期望的。只要一个成员函数没有修改对象的“外部可见的逻辑状态”,它就应该是 const 的。
在大多数情况下,两者是一致的。但有时,为了维持“逻辑上的不变”,我们可能需要修改一些“内部实现上的细节”。mutable 就是用来实现“逻辑 Const”的工具。
mutable用例
用例 1:缓存 (Caching) / 延迟计算 (Memoization) 假设你有一个类,它有个函数需要执行非常昂贵的计算,但这个计算结果在对象生命周期内是固定的。
1 | class ExpensiveCalculator { |
用例 2:用于 Mutex 假设我们有一个类,它的大多数成员函数都是 const(因为它们只是读取数据),但我们希望这些读取操作在多线程环境下是安全的。
1 | class ThreadSafeReader { |
这是 mutable 最常见、最标准的用途之一:允许 const 成员函数获取锁以实现线程安全。
mutex 互斥量
mutex 不是关键字,它是 C++11 在
它是多线程编程中的一种同步原语(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 |
|
std::atomic
我们之前讨论了 mutex(互斥锁)。mutex 采用阻塞 (Blocking), 是解决多线程数据竞争的一种重量级的同步机制。而 std::atomic 提供了一种非阻塞 (Non-Blocking) 的同步方式。下面我们先了解一下 std::atomic
多线程阻塞这种“挂起”和“唤醒”操作涉及到了操作系统内核态与用户态的切换,这是一个非常昂贵(高延迟)的操作。对于那些需要频繁更新、但冲突并不严重的共享变量(例如一个全局访问计数器),使用 mutex 就像“杀鸡用牛刀”,开销太大了。
std::atomic 是 C++11 引入的一个极其重要的特性,它位于
std::atomic 提供了一种能力,使得对某个变量的简单操作(如读、写、增、减、交换等)可以原子性 (Atomically) 地完成。它依赖于 CPU 硬件层面提供的原子指令 (Atomic Instructions)(例如 x86 上的 LOCK CMPXCHG)。这些特殊的 CPU 指令可以在硬件层面保证一个“读-改-写”的操作序列在执行的中途不会被任何其他线程打断(即它是原子的)。
当一个变量被声明为 std::atomic 时,编译器会确保对这个变量的所有操作都转化为这些特殊的原子 CPU 指令,而不是我们之前担心的普通的“读、改、写”三部曲。
上述示例中采用std::atomic的同款代码如下:
1 |
|
最重要的是,这个过程完全在用户态完成,没有线程会被操作系统挂起(阻塞)。如果发生冲突,线程通常只会进行几次“自旋 (Spin)”(即 CPU 空转几个周期再试一次),这比内核调度的开销小得多。