您的位置:首页 > 编程语言 > C语言/C++

Effective Modern C++翻译系列之Item16

2017-08-17 20:35 555 查看
Item 16:Make const member functions thread safe.
如果我们工作在数学领域,也许我们会发现用一个类来代表多项式是很方便的。在这个类中,有可能有一个函数被用来计算多项式的root,例如,当多项式等于0时的值。这样的函数将不会修改多项式,所以很自然的我们会把他声明为const:
class Polynomial {
public:
using RootsType = //多项式等于0时保存着值的数据类型
std::vector<double>;
...
RootsType roots() const;
};
计算多项式的roots会很昂贵,所以如果不是我们必须要那么做,我们不会想做这件事。并且如果我们必须要做这件事,我们也不会想做第二次。我们会把多项式的roots存储起来,然后我们执行roots时会返回存储的值。下面是基本的实现:
class Polynomial {
public:
using RoosType = std::vector<double>;
RootsType roots() const
{
if(!rootsAreValid) { //如果没有存储该值,
... //计算roots,存储在rootVals中
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{ };
};
从概念上来讲,roots不会改变它操纵的Polynomial对象,但是,作为它存储活动的一部分,它也许需要修改rootVals和rootsAreValid。这是mutable一个典型的应用情况,这也是为什么它是这两个数据成员声明的一部分。
想象一下现在有两个线程同时调用Polynomial对象的roots函数:
Polynomial p;
...
/*-----------Thread 1-------------*/      /*------------Thread 2-----------*/
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
这段客户代码有理有据,令人信服。roots是一个const成员函数,这意味着它代表了一个读操作。非同步多线程执行一个读操作是安全的。至少它应该是。这个例子中,却不是这样的,因为在roots中,一个或者两个线程也许要修改数据成员rootsAreValid和rootVals。这意味着这段代码可能有不同的线程非同步的读和写相同的内存,这被称为数据竞争。这段代码有着未定义的行为。
问题在于roots被声明为const,但是它不是线程安全的。c++11中的const声明和c++98中一样正确(恢复一个多项式的roots不会改变多项式的值),所以需要被修正的是线程安全的缺失:
解决这个问题最简单通常的方法是:使用mutex:
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
std::lock_guard<std::mutex> g(m); //锁mutex
if(!rootsAreValid) {
...
rootsAreValid = true;
}
return rootVals;
} //解锁mutex
private:
mutable std::mutex m;
mutable bool rootsAreValid { false };
mutable RootsType rootsVals{};
};
std::mutex m 被声明为mutable,因为锁和解锁它是非const成员函数,在roots中(一个const成员函数),m会被视为一个const对象。
值得关注的是因为std::mutex既不能被拷贝也不能被移动,像Polynomial对象中添加m一个次要的影响是Polynomial失去类被拷贝和移动的能力。
有些情况中,用mutex是小题大做了。例如,如果你做的就是统计一个成员函数被调用了多少次,一个std::atomic counter(其他线程保证一旦它出现,会不可分割的调用它的操作)常常是一个更实惠的方法。(它到底是不是更实惠取决于你运行的硬件和标准库中mutexes的实现)。这是你应该如何使用std::atomic来统计调用次数:
class Point {
public:
...
double distanceFromOrigin() const noexcept
{
++callCount;
return std::hypot(x,y);
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x,y;
};
像std::mutexes,std::atomics是不可拷贝和不可移动的,所以Point中callCount的存在意味着Point也是不可拷贝和移动的。
但是std::atomic变量上的操作经常要比mutex的获取和释放要实惠,你也许会被诱惑着太倾向于使用std::atomics。例如,一个类中存储一个计算消耗很大的int型值,你也许会尝试着使用一对std::atomic变量而不是一个mutex:
class Widget {
public:
...
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cacheValue;
};
这将会有用,但是有时候它将会比它本应该工作的更艰难。想象一下:
一个线程调用Widget::magicValue,看到cacheValid是false,执行了两个昂贵的计算操作,将他们的和赋值给cachedValue。
在这个时候,第二个线程调用Widget::magicValue,也看到cacheValid是false,并且因此进行了第一个线程刚结束的两个昂贵的计算操作。(这第二个线程事实上可能是几个)
要解决这个问题,你也许想着交换cachedValue的赋值操作和cacheValid的赋值操作,但是你很快认识到(1)多线程可能仍然会计算val1和val2(在cacheValid被设置为true前),thus
defeating the point of the exercis,并且(2)事实上它会让事情变得更糟。考虑一下:
class Widget {
public:
...
int magicValue() const
{
if(cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheValid = true;
return cachedValue = val1 + val2;
}
}
...
};
想象cacheValid是false,并且:
一个线程调用Widget::magicValue函数并且执行到cacheValue被设置为true的地方。
在这个时候,第二个线程调用Widget::magicValue并且检查cacheValid。看到它是true,线程返回了cachedValue,尽管第一个线程还没有对它进行赋值。返回值将会是不正确的。
这里有一个教训:对于单个变量或是内存需要同步,使用std::atomic是合适的,但是一旦你使用两个或者更多的变量或是内存需要同步(作为一个集合),你应该使用mutex。对于Widget::magicValue,它应该看起来像这样:
class Widget {
public:
...
int magicValue() const
{
std::lock_guard<std::mutex> guard(m);
if(cacheValid) return cacheValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cacheValue;
}
}
...
private:
mutable std::mutex m;
mutable int cachedValue;
mutable bool cacheValid { false };
};
现在,这个Item基于假设多个线程同时执行一个对象上的一个const成员函数。如果你正在写一个const成员函数,它不在这种情况下------不会超过一个线程在一个对象上执行该成员函数-----函数的线程安全不是那么重要。例如,为单线程使用而设计的成员函数是不是线程安全的是不重要的。在这些例子中,你可以避免mutex和std::atomics相关的开销以及包含它们的类不可拷贝和不可移动的影响。然而,这些线程自由的情形越来越少见,并且它们很可能会变得十分稀有。安全的做法是const成员函数对于同步执行应该是满足的,这就是为什么你应该确保你的const成员函数应该是线程安全的。
 
Things to Remember
 
1.让const成员函数线程安全,除非你确信他们永远不会被用在同步情形下。
2.std::atomic变量的使用也许会提供比mutex更好的表现,但是它仅适合单个变量或者内存位置的控制。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: