目录
[条款16(成员函数):保证const成员函数的线程安全(Make const member functions thread safe)](#条款16(成员函数):保证const成员函数的线程安全(Make const member functions thread safe))
条款16(成员函数):保证const成员函数的线程安全(Make const member functions thread safe)
既然const成员函数(只读操作)承诺不会修改对象的成员数据,那为什么还需要保证其线程安全性。这其实是对const成员函数的误解。const成员函数存在两种解读:一种是bitwise constness(位常量性/物理常量性),这是编译器遵循的规则,只要对象数据的任何一个比特位发生变化,就认为是修改了对象;另一种是logical constness(逻辑常量性),这是程序员模拟的规则,可能存在某些辅助变量发生变化,也认为是从业务逻辑上看对象依然保持不变。
互斥量:多个变量/内存位置
案例分析 :考虑一个用于表示**"多项式(** Polynomial **)"**的类
成员变量 :coeffs(图中未显示)
- 类型:std::vector<double>
- 作用 :存储多项式的系数 → coeffsi = xi的系数
- 示例:3x² + 2x + 1存储为coeffs = {1, 2, 3}
成员函数 :roots()
- 作用 :计算多项式的根(多项式=0的解)
- 返回值 :存储多项式的根,RootsType类型(std::vector<double>的别名)
- 常量性 :由于该函数在逻辑上不会修改多项式的系数 ,因此将它声明为 const 成员函数 是合理的。
实现1 (不可行) :不缓存 多项式的根,天然具备多线程安全性程序设计:在每次调用roots()时,都重新计算多项式的根,并将结果返回,而不进行任何缓存。
问题分析 :计算多项式的根可能代价高昂 ,因此我们通常希望尽量避免执行这一计算 ,除非确实不可避免。一旦必须进行该计算,我们也希望只执行一次,而不是重复进行相同的计算。
实现2 (不可行) :缓存 多项式的根,但不保证多线程安全性程序设计 :为了避免重复计算,可以引入缓存机制。具体地,在**"首次计算"** 多项式的根时,将这些根缓存起来。在返回值方面,采用**"返回缓存值"** 的手法。
- rootsAreValid=false :缓存值rootVals不可用 → 计算 多项式的根(并缓存),再返回缓存值
- rootsAreValid =true :缓存值rootVals可用 → 直接返回缓存值
- 实现细节 :++理论上++ ,const成员函数承诺不会修改 它操作的Polynomial 对象 。++实际上++ ,const成员函数的确需要修改 Polynomial 对象 (缓存标志rootsAreValid和缓存值rootVals),以实现"缓存多项式根"的功能。这两者并不存在矛盾 ,具体分析如下:这两个成员并不属于 Polynomial 对象的一部分 (只有多项式的系数才属于Polynomial对象),而属于实现层面的辅助成员 (仅用于缓存计算结果)。它们是否发生修改不会影响对象本身(不会改变多项式本身的数学含义)。因此,将它们声明为mutable,使得const成员函数可以修改这些成员(否则编译不会通过)(mutable关键字的经典用例)。
- 问题分析 :引入缓存的相关成员后,会破坏线程安全性。++理论上++ ,const成员函数通常被视为只读操作 ,因此人们往往认为多个线程 在没有额外同步机制的情况下同时调用 const 成员函数 是安全的 ,或者至少应当被视为安全的。这种理解的前提是:const 成员函数不会修改对象的状态,只执行读取操作。++实际上++ (然而),这种假设并不总是成立。const成员函数虽然不能修改普通成员变量 ,但仍然可以修改被声明为 mutable 的成员变量 。在此种情况下,多个线程同时调用 const 成员函数 是不安全的 ,可能在没有同步的情况下读写同一块内存,引发数据竞争( data race ) ,从而导致未定义行为。因此,需要修正线程安全性的缺失。
实现3 (可行) :缓存 多项式的根,同时保证多线程安全性
- 程序设计 :使用标准库提供的互斥锁std::mutex
- 在创建lock_guard对象时(到达定义式),构造函数内部对互斥锁进行加锁。
- 在销毁lock_guard对象时(退出作用域),析构函数内部对互斥锁进行解锁。
- 实现细节:std::mutex m被声明为mutable,否则在const成员函数内部将被视为const对象,无法加锁和解锁。
- 注意事项 :由于std::mutex是一种仅可移动类型( move-only type ) (即只能移动但不能拷贝 的类型),将m添加到Polynomial的副作用 就是导致 Polynomial 失去"可拷贝性"(但仍然可移动)。
原子变量:单个变量/内存位置
案例分析 :考虑一个用于表示**"二维点(** Point **)"**的类
成员变量 :double x, y; 表示二维点的x和y坐标
成员函数 :distanceFromOrigin() 计算当前点到原点的距离
业务需求:统计成员函数被调用的次数
实现1 (不可行) :不计算 调用次数,天然保证多线程安全性程序设计:
问题分析:不满足业务需求
实现2 (不可行) :计算 调用次数,但不保证多线程安全性程序设计 :为了统计函数调用次数,可以引入**"整型计数器"**。具体地,每调用一次函数,对计数器进行自增操作。
问题分析 :引入计数器的相关成员后,会破坏线程安全性。
实现3 (可行,不推荐) :计算 调用次数,同时保证多线程安全性程序设计 :利用**"互斥量"**
问题分析 :互斥量的成本较高(杀鸡用牛刀之举)。
实现4 (可行,推荐) :计算 调用次数,同时保证多线程安全性程序设计 :利用**"原子变量"**(使用std::atomic类型的计数器)
- 性能分析 :原子变量的成本较低(是否真的成本较低,取决于硬件以及所使用标准库中互斥量的实现)。
- 注意事项 :与std::mutex一样,std::atomic也是一种仅可移动类型 ,将callCount添加到Point的副作用 就是导致 Point 失去"可拷贝性"(但仍然可移动)。
综合比较
由上可知,与"std::mutex的加锁、解锁"操作相比,"std::atomic类型变量"操作通常具有更低的开销。因此,人们可能会过度依赖std::atomic类型的对象。
实现1 (不可行) :不缓存 计算结果,天然保证多线程安全性
实现2 (不可行) :缓存 计算结果,但不保证多线程安全性
实现3 (可行,推荐) :缓存 计算结果,同时保证多线程安全性
实现4.1 (可行,不推荐) :缓存 计算结果,同时保证多线程安全性
- 程序设计:尝试使用一对std::atomic类型的变量来取代互斥量。
问题分析 :难以避免有时出现重复计算的情况(分析如下),从而违背"使用缓存"的目。
- 当一个线程调用magicValue()时,观察到cacheValid为false,因此执行这两个开销较大的计算,并将其和赋值给cachedValue。
- 与此同时,另一个线程也在调用magicValue(),也观察到cacheValid为false,因此执行第一个线程刚刚完成的两次同样的大开销计算。(此处"另一个线程"实际上有可能是另外若干个线程)
实现4.2 (不可行) :缓存 计算结果,同时保证多线程安全性程序设计:将cachedValue和CacheValid的赋值顺序交换。
- 问题分析 :尽管这种方式可以解决重复计算的问题,但结果更糟糕了。假设cacheValid是false,那么:
- 一个线程调用magicValue(),刚执行完将cacheValid设置true的语句。
- 此时,另一个线程也在调用magicValue(),并检查cacheValid的值。观察到其值为true后,该线程就返回cacheValue值,即使第一个线程还没有执行对cacheValue的赋值。因此,返回值是不正确的。
小结
结论1.1 :"使用 std::atomic 类型的变量" 会比**"使用互斥量"**提供更好的性能。
结论1.2 :对于单个 要求同步的变量或内存区域,使用原子变量 std::atomic 就足够了。然而,如果有两个或更多个 的变量或内存区域作为一整个单元进行操作时,就应该使用互斥量 std::mutex 。(重要!!!)
结论2:确保const成员函数的线程安全性,除非可以确信它们不会用在并发语境中。
现在,本条款的陈述都是基于这样的假设,即多个线程 会同时调用在同一个对象 的同一个 const 成员函数 。如果撰写的成员函数并不符合这种情况 (换言之,可以保证永远不会有多于一个线程 来调用在同一个对象 上该成员函数 ),那么该函数的线程安全是无关紧要的(可有可无的)。比如,为独占单线程使用而设计的类,其成员函数是否具备线程安全性并不重要。在这种情况下,你可以避免 由于使用"互斥量和std::atomic类型的对象"所带来的开销 ,同时还可以消除 "包含它们的类"变成"只能移动不可拷贝类"的副作用。
然而,这种线程无关的场合正在变得越来越罕见 ,而且会变得更加罕见。可以肯定地说,const成员函数都会运行在并发执行的条件下,因此你应该确保const成员函数具备线程安全性。












