问题实例
假设当前存在一个类以表征多项式,且该类存在一个成员函数以完成多项值求根功能。显然此类函数不可能对多项式造成任何修改,因此可将其声明为const:1
2
3
4
5
6
7class Polynomial {
public:
using RootsType = std::vector<double>;
…
RootsType roots() const;
…
};
一般而言,求根操作成本高昂,我们理应避免重复计算,因此我们在某次计算完成后将其值作为缓存,每一次求根都从缓存中取值(此思想可见More Effective C++ Item18):1
2
3
4
5
6
7
8
9
10
11
12
13
14class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const{
if (!rootsAreValid) { // if cache not valid
… // compute roots,store them in rootVals
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};
roots成员函数不可能修改Polynomial对象,但它可能会修改rootVals和rootsAreValid,此二者被声明为mutable,其本意便在于方便const函数修改。
假设当前存在两个线程同时在Polynomial对象上调用roots:1
2
3
4Polynomial p;
…
/*----- Thread 1 ----- */ /*------- Thread 2 ------- */
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
在客户端执行上述程序十分合理,因为roots具备const属性,客户可以认为其执行读操作,而多线程在无同步的条件下执行读取操作在概念上是安全的。但实际上我们知道,roots可能会改变对象内部的data member,这意味着可能存在不同的线程在无同步的条件下对同一块内存进行读取与写入操作,这就是data race的标准定义。因此,该程序可能会导致未定义行为。
关键问题在于roots的const属性。C++98中的const成员函数仅仅表示不会修改对象,但C++11中它需要在此基础之上更进一步:保证线程安全性。
解决方案
解决此问题最简单的方法(或许也是最常见的方法)是使用互斥锁:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const{
std::lock_guard<std::mutex> g(m); // lock mutex
if (!rootsAreValid) { // if cache not valid
… // compute and store roots
rootsAreValid = true;
}
return rootVals;
} // unlock mutex
private:
mutable std::mutex m;
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};
我们将std::mutex声明为mutable,因为锁定与解锁操作均在const成员函数中进行。此外需要注意的是,mutex是一种move-only type(无法被复制,仅能被移动),因此mutex的加入导致多项式对象也变成了一个move-only object,从此失去了被复制的能力。
std::atomic
在某些情况下,使用mutex可能有些小题大做。例如我们当前正关注于一个成员函数被调用的次数,那么使用一个std::atomic计数器(见Item40)可能更加恰当(实际上是否比mutex更加好用取决于硬件与你所使用的mutex的实现)。下述实例展示了如何使用std::atomic计算成员函数被调用的次数:1
2
3
4
5
6
7
8
9
10
11class Point {
public:
…
double distanceFromOrigin() const noexcept{
++callCount; // atomic increment
return std::sqrt((x * x) + (y * y));
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};
类似于mutex,atomic也属于move-only type,因此Point对象也必将是一个move-only object。
atomic的不足
由于atomic的使用成本相较于mutex较为低廉,因此我们可能会更倾向于使用atomic。举例而言,如果当前有一个很消耗计算量的int值需要被缓存,我们可能会倾向于使用一对atomic而非mutex来确保线程安全性:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Widget {
public:
…
int magicValue() const {
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; // part 1
cacheValid = true; // part 2
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};
上述程序能够运行,只是运行流程未必如你所想,试考虑如下情形:
- 某线程调用magicValue,发现当前cacheValid处于false状态,于是默默计算了一遍。
- 就在此时,第二个(或者其他)也调用了magicValue,也发现当前cacheValid处于false状态,于是进行了一次无意义的重复计算。
这种行为与我们设定缓存的目标背道而驰。将颠倒cacheValid与cachedValue的赋值顺序可以解决上述问题,但你会发现我们将得到一个更糟的结果:1
2
3
4
5
6
7
8
9
10
11
12
13
14class Widget {
public:
…
int magicValue() const{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheValid = true; // part 1
return cachedValue = val1 + val2; // part 2
}
}
…
};
假设当前cacheValid为false,此后:
- 莫线程调用了Widget::magicValue 并且通过Point对象将caheValid设置为1;
- 就在此时,另一个线程发现当前cacheValid为1,于是直接把cachedValue拿去用了(此时第一个线程尚未完成计算)。
由此我们可以发现,对于需要同步的单个变量或内存位置,std::atomic足以应对,但如果需要操作的两个或多个变量或内存位置,则应当使用mutex完成相关任务。因此,magicValue的正确实现应当为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Widget {
public:
…
int magicValue() const{
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // unlock m
…
private:
mutable std::mutex m;
mutable int cachedValue; // no longer atomic
mutable bool cacheValid{ false }; // no longer atomic
};
const成员函数的线程安全性
本节内容的前提是可能存在多个线程同时在某对象上执行const成员函数,并且该const函数试图修改data member。如果你确保自己的程序不会应用于多线程环境下,那么const成员函数的线程安全性并不重要。随着多线程的普及度越来越高,这种可能性似乎越来越小。可以肯定的是,const成员函数必将受到并发编程的影响,这就是我们应当确保const成员函数是线程安全的原因所在。
总结
- 开发者应当致力于保证const成员函数的线程安全性,除非你的程序永不涉及并发编程。
- std::atomic变量可以提供相对于mutex更加优良的性能,但其仅适用于操作单个变量或内存位置。