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

YiQi 管理员

在数学领域中,使用一个类来表示多项式会非常方便。而在这个类中,若有一个函数能够计算多项式的根,即那些使得多项式求值结果为零的值,将会很有用。这样一个函数并不会造成多项式的值的改动,因此将它声明为 const 成员函数也很自然。

计算多项式的根也许代价高昂,在不得不计算多项式的根时,我们就把这些根缓存起来,并以返回缓存值的手法来实现 roots。以下是一种基本的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
if (!rootsAreValid) { // if cache not valid compute roots,

rootsAreValid = true; // store them in rootVals
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false }; // see Item 7 for info
mutable RootsType rootVals{}; // on initializers
};

从概念上说,roots 不会改变它操作的 Polynomial 对象,然而作为缓存活动的组成部分,它可能需要修改 rootValsrootsAreValid 的值。这是 mutable 的经典用例, 也是它为何被加到数据成员声明中。

设想现在有两个线程同时在同 一个 Polynomial 对象上调用 roots:

1
2
/*----- Thread 1 ----- */ /*------- Thread 2 ------- */
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();

问题就在于,roots 被声明成了 const 函数,但却并非线程安全的。要解决这个问题,最简单的办法也是最常见的:引入一个 mutex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
std::lock_guard<std::mutex> g(m); // lock mutex
if (!rootsAreValid) {

rootsAreValid = true;
}
return rootVals;
} // unlock mutex
private:
mutable std::mutex m;
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};

值得关注的是,由于 std::mutex 是个只移型别 (move-only type) (即只能移动但不能复制的型别),将 m 加入 Polynomial 的副作用就是 Polynomial 失去了可复制性。不过,它仍然可移动。

就一些特定情况而言,引入互斥址是杀鸡用牛刀之举。以下代码演示了如何使用 std::atomic 型别的对象来计算调用次数:

1
2
3
4
5
6
7
8
9
10
11
class Point { // 2D 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;
};

由于对 std::atomic 型别的变量的操作与加上与解除互斥量相比,开销往往比较小,你也许应该尝试比惯常程度更重度地依靠 std::atomic 型别的对象,例如,如果某类需要缓存计算开销较大的 int 型别的变最,则应该尝试使用一对 std::atomic 型别的变量来取代互斥量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Widget {
public:

int magicValue() const {
if (cacheValid)
return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; // uh oh, part 1
cacheValid = true; // uh oh, part 2
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};

但会存在两个大开销计算在不同的线程被同时执行的问题,因为 cacheValid 可能会被同时观察为 false。这里我们学到的教益是:对于单个要求同步的变量或内存区域,使用 std::atomic 就足够了。但是如果有两个或更多个变量或内存区域需要作为一整个单位进行操作时,就要动用互斥量了

要点速记

  • 保证 const 成员函数的线程安全性,除非可以确信它们不会用在并发语境中。
  • 运用 std::atomic 型别的变量会比运用互斥量提供更好的性能,但前者仅适用对单个变量或内存区域的操作。
此页目录
EMCPP条款16:保证const成员函数的线程安全性