目录
条款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>
- 作用:存储多项式的系数 → coeffs[i] = 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成员函数具备线程安全性。
网硕互联帮助中心



评论前必须登录!
注册