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

目录

[条款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成员函数具备线程安全性。

相关推荐
j7~7 小时前
【Linux】二十七.线程篇四《Linux多线程编程:线程互斥(互斥量的底层到封装)、线程安全和冲入、死锁》---详解
linux·开发语言·c++·线程安全·死锁·线程互斥·重入
华科大胡子7 天前
条款17:理解特殊成员函数的生成
modern c++·特殊成员函数·大三律·生成机制
想你依然心痛14 天前
【共创季稿事节】HarmonyOS 7.0 NAPI 实战:C/C++ 原生模块开发与性能加速
内存管理·线程安全·异步任务·napi·harmonyos 7.0·c++桥接·性能加速
华科大胡子14 天前
条款14:如果函数不抛出异常,请使用 noexcept
异常·noexcept·modern c++
是个西兰花17 天前
Linux:深入解析Linux线程原理与实现
linux·运维·c++·线程·互斥锁
rebibabo19 天前
Java高并发底层原理(二十四)—— 线程中断如何让任务安全停止
线程安全·线程中断·java并发·协作式停止·线程池关闭·中断标记
华科大胡子21 天前
条款13:优先选用const_iterator,而非iterator
iterator·modern c++·const_iterator
Brookty1 个月前
【JavaEE】线程安全(一).4:写块串行保安全、CAS
java·开发语言·java-ee·多线程·线程安全
华科大胡子2 个月前
条款08:优先选用nullptr,而非0和NULL
nullptr·null·modern c++·空指针