- 本节作者想表达的 point 是,由于 const 成员函数代表的一般是只读操作,客户端的调用代码有理由在多线程调用中不考虑同步问题。但实际上有时在 const 函数中也需要修改数据成员,此时函数的线程安全性就应该由你来保证。当然,作者也点明了如果你能确保函数是100%不会涉及多线程调用场景,的确没必要考虑这点,然而实际上完全非多线程的应用场景是越来越少的。
- 假设有一个 Point 类,其中有一个返回到原点距离的函数 distanceFromOrigin,显然它可以是 const 的。现在如果我们有一个需求是要记录该函数的调用次数,就需要在函数中改变某个计数变量的值。解决方法是使用 mutable 关键字声明计数变量,这样的变量可以在 const 函数中被修改。为了保证线程安全,我们还可以用 std::atomic 来封装该计数变量:
class Point {
public:
...
double distanceFromOrigin() const noexcept {
++callCount;
return std::sqrt((x * x) + (y * y));
}
...
private:
mutable std::atomic callCount{ 1 };
double x, y;
};
- 需要注意,由于 std::atomic 只有移动构造,包含它的 Point 类也只能移动构造而不能用复制构造函数或赋值运算符(=)构造。以下所述的 std::mutex 也有这一性质。当处理多变量时,使用 std::atomic 可能无法解决数据竞争,例如有如下函数:
class Widget {
public:
...
int magicValue() const {
if (cachedValue) return cachedValue;
else {
auto val1 = expensiveComputation1(); // 某个运算量很大的函数
auto val2 = expensiveComputation2(); // 同上
cachedValue = val1 + val2; // 二者谁先谁后?
cachevalid = true; // 答案是都不好!
return cachedValue;
}
}
...
private:
mutable std::atomic cachevalid = false;
mutable std::atomic cachedValue; // 通过缓存避免大计算量的重复计算
- 仔细思考会发现,cachedValue 和 cachevalid 二者赋值的两种顺序都是不好的:
- cachedValue 先赋值,可能当线程1完成大运算量计算向 cachedValue 赋值时,线程2检查 cachevalid 为 false,于是重复计算;
- cachevalid 先赋值,可能当线程1完成计算并已向 cachevalid 赋值 true,但还未向 cachedValue 赋值时,线程2检查 cachevalid 为 true,于是直接取走了错误的 cachedValue,结果更糟糕。
- 正确的做法是不要让两个线程同时进入到这段函数中。std::mutex 是一个信号量(锁),上锁期间只允许一个线程进入,其他线程阻塞。std::lock_guard 可以通过一个 std::mutex 构造,初始化时即上锁,离开当前作用域(大括号范围)自动析构解锁。以上函数重写如下:
class Widget {
public:
...
int magicValue() const {
std::lock_guard g(m);
if (cachedValue) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cachevalid = true;
return cachedValue;
}
}
...
private:
mutable std::mutex m;
mutable std::atomic cachevalid = false;
mutable std::atomic cachedValue;
};
总结
- 确保 const 成员函数线程安全,除非你确定它们永远不会被用在并发场景中。
- 使用 std::atomic 或许性能会优于信号量(mutex),但它们仅适用于对单变量的操作。