Effective Modern C++翻译系列之Item16

来源:互联网 发布:婚纱网络客服 编辑:程序博客网 时间:2024/06/06 17:43

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对象,但是,作为它存储活动的一部分,它也许需要修改rootValsrootsAreValid。这是mutable一个典型的应用情况,这也是为什么它是这两个数据成员声明的一部分。

想象一下现在有两个线程同时调用Polynomial对象的roots函数:

Polynomial p;

...

/*-----------Thread 1-------------*/      /*------------Thread 2-----------*/

auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();

这段客户代码有理有据,令人信服。roots是一个const成员函数,这意味着它代表了一个读操作。非同步多线程执行一个读操作是安全的。至少它应该是。这个例子中,却不是这样的,因为在roots中,一个或者两个线程也许要修改数据成员rootsAreValidrootVals。这意味着这段代码可能有不同的线程非同步的读和写相同的内存,这被称为数据竞争。这段代码有着未定义的行为。

问题在于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是不可拷贝和不可移动的,所以PointcallCount的存在意味着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,看到cacheValidfalse,执行了两个昂贵的计算操作,将他们的和赋值给cachedValue

在这个时候,第二个线程调用Widget::magicValue,也看到cacheValidfalse,并且因此进行了第一个线程刚结束的两个昂贵的计算操作。(这第二个线程事实上可能是几个)

要解决这个问题,你也许想着交换cachedValue的赋值操作和cacheValid的赋值操作,但是你很快认识到(1)多线程可能仍然会计算val1val2(在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;

}

}

...

};

想象cacheValidfalse,并且:

一个线程调用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成员函数,它不在这种情况下------不会超过一个线程在一个对象上执行该成员函数-----函数的线程安全不是那么重要。例如,为单线程使用而设计的成员函数是不是线程安全的是不重要的。在这些例子中,你可以避免mutexstd::atomics相关的开销以及包含它们的类不可拷贝和不可移动的影响。然而,这些线程自由的情形越来越少见,并且它们很可能会变得十分稀有。安全的做法是const成员函数对于同步执行应该是满足的,这就是为什么你应该确保你的const成员函数应该是线程安全的。

 

Things to Remember

 

1.让const成员函数线程安全,除非你确信他们永远不会被用在同步情形下。

2.std::atomic变量的使用也许会提供比mutex更好的表现,但是它仅适合单个变量或者内存位置的控制。