简介
const
(可以用constexpr
替换见Item15)成员函数和线程安全是本文的一个核心主题,两者究竟是如何联系在一起的呢?这一切都源于mutable
,本文试图从以下几个方面介绍。
- 是什么导致const成员函数变成了非线程安全
- 如何避免非线程安全问题,由这个问题又引入了下面两个问题:
- 什么时候应该使用原子变量
- 什么时候应该使用
mutex
锁
是什么导致const成员函数变成了非线程安全
首先来说一说第一个问题,const
成员函数的线程安全问题,毫无疑问,它是线程安全的,因为const成员函数是不允许对类的成员变量有修改操作的,也就是只能读,那么这必定是线程安全的,但是为何本文的题目却是让const
成员函数线程安全呢?,这里需要谈一谈mutable
关键字,这个关键字是用来修饰类的成员变量的,目的是让const
成员函数可以修改 那些使用mutable
修饰的变量。有了mutable
那么const
将不再是线程安全的了,因为有写操作了,多线程读写同一个变量是非线程安全的。
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const {
if (!rootsAreValid) {
....
rootsAreValid = true
}
return rootVals;
}
private:
mutable bool rootsAreValid { false };
mutable RootsType rootVals{};
};
上面的这段代码中,roots
本身是一个const
成员函数,每次返回rootVals
,而这个值只有在第一次的时候才需要计算,此后只需要直接返回即可,所以这就有了一个rootsAreValid
来表明这个值是否是已经计算的了。此时如果有两个线程同时执行roots
,第一个线程发现rootsAreValid
是false,开始计算rootVals
,在计算的过程中,第二个线程开始执行,发现rootsAreValid
也是false,也开始计算rootvals,如果这个计算的过程不是幂等性那么势必会导致计算结果不符合预期。
如何避免非线程安全问题
如何避免线程安全,这让我第一个就会想到锁,所以就有了第一个改进的版本,如下:
RootsType roots() const {
std::lock_guard<std::mutex> g(m)
if (!rootsAreValid) {
....
rootsAreValid = true
}
return rootVals;
}
上面的代码中引入了一个mutable
的mutex
,因为加锁和解锁本身是会对mutex
本身有改动,所以是mutable
,不过这也带来了另外一个问题,就是开销变大了,后面每次都要加锁获取rootVals
,但是其实只有第一次是可读可写的,非线程安全的,后面就变成只读的了是线程安全的,还有另外一个问题就是mutex
本身其实是一个只具备移动语义的类,这导致Polynomial
类相应也变成了只具备移动语义的类了,限制了Polynomial
类的使用范围。既然锁的开销比较大那就换原子变量,用原子变量保证rootsAreValid
是原子的, 在计算rootVals
之前先把rootsAreValid
设置为true
确保只有一个线程进入计算rootVals
的临界区中。但是这会导致其它线程读取到未经计算过的rootVals
(第一个线程正在计算),这反应了原子变量在这种情况下很难保证线程安全,它只能保证单个变量的原子性,如果有多个可变的值,尽管可以通过原子变量保证每一个都是原子的,但还是无法从逻辑上保证整体上的线程安全性,下面这种情况就比较适合使用原子变量。
class Point {
public:
double distanceFromOrigin() const noexcept
{
++callCount;
return std::sqrt((x * x) + (y * y));
}
private:
mutable std::atomic<unsigned> callCount{0};
double x, y;
}
上面只有一个callCount
是可变的,因此在这里完全可以使用原子变量来保证其线程安全性。总结来说,使用原子变量可以提供比mutex
更好的性能,但是它只适用于保护单个变量或内存位置。