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

Effective Modern C++ 条款16 使const成员函数成为线程安全函数

2016-08-23 19:59 681 查看

使const成员函数成为线程安全函数

如果我们在数学领域工作,我们可能发现一个表示多项式的类会对我们很便利。。在这个类中,一个计算多项式的根的函数是很有必要的,也就是求多项式等于0时字母的值。这个函数不会改变多项式,所以我们把它声明为const

class Polynomial {
public:
using RootsType =            // 该数据结构存储
std::vector<double>;   // 多项式等于0时的根
...
RootsType roots() const;
...
};


计算多项式的根这个操作很耗时,所以我们只有在不得已的情况下才使用它。如果我们真的进行了计算,那么我们肯定不想计算第二次了,因此我们把计算结果缓存起来,然后roots函数返回缓存的值。这里是最基本的方法:

class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
if (!rootsAreValid)  {   // 如果无缓存值
...         // 计算根,把结果存在rootVals
rootsAreVaild = true;
}
return rootVals;
}
private:
mutable bool rootAreValid{ false };   // 括号初始化看条款7
mutable RootsType rootVals{};
};


从概念上讲,roots函数不会改变多项式对象,但是,它的计算并缓存行为,需要修改rootVals和rootAreValid。这是一个mutable典型使用例子,也就是为什么mutable是成员变量声明的一部分。

现在我们来想象一下两个线程并发调用一个多项式对象的roots函数:

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


这用户的代码是合情合理的。roots函数是一个const成员函数,那意味着函数表示的是一个读操作,多线程下不带同步原语使用读操作是安全的。至少我们的假设是这样的。在这个例子中,它不安全,因为在root里面,其中一个或所有的线程都有可能试图改变成员变量rootsAreValid和rootVals。这意味着这用户代码可以有不同的线程不同步地读写相同的内存,那很明显就是数据竞争(data race)了,这样代码就有未定义行为。

问题其实是roots函数被声明为const,它却不是线程安全的。在这里使用const声明,在C++11和C++98都是正确的(取得多项式的跟根本不需要改变多项式对象的值),所以我们要矫正的是线程安全的缺乏。

解决这问题最简单的办法其实就是平常的方法:使用mutex

class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const
{
std::lock_guard<std::mutex> g(m);    // 加锁

if (!roorsAreValid) {
...
rootsAreValid = true;
}
return rootVals;
}                                                              // 解锁
private:
mutable std::mutex m;
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};


std::mutex对象m被声明为mutable,因为加锁和解锁mutex都是non-const成员函数(即会改变mutex对象的值或状态),然后在roots内(const成员函数),m可以被看作是个const对象(因为const成员函数承诺不会改变成员变量的值或状态)。

值得注意的是std::mutex是一个只可移动类型(move-only type,即只可移动,不可拷贝的类型),把m添加到多项式对象的副作用是多项式失去了被拷贝的能力,但它仍可以被移动。

在一些情况中,互斥锁杀伤力太强。例如,如果你需要计数一个成员被调用了多少次,那么用一个std::atomic计算器(原子操作类型,看条款40)将会减少很多开销(实际上是否会减少开销需要看你机器上的互斥锁实现)。这里是你使用一个std::atomic变量来计数:

class Point {    // 二维坐标
public:
...
double distanceFromOrigin() const noexcept  // noexcept看条款14
{
++callCount;    // 原子递增
return  std::sqrt((x * x) + (y * y ));
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};


std::mutex一样,std::atomic也是只可移动类型,所以Point对象也只是可以移动。

因为操作std::atomic变量的开销通常会比获取锁和释放锁要小,你可能就会重度依赖std::atomic。例如,在一个类中缓存一个计算耗时长的int,你可能会试图用一对std::atomic变量来代替互斥锁:

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;
mutable std::atomic<int> cachedValue;
};


这代码可以运行,但有时候它会运行得很困难,考虑以下:

一个线程调用Widget::magicValue,看到cacheValid的值是false,然后进行两个昂贵的计算,然后把它们的和安置在cachedValue。

在这个时刻,第二个线程调用Widget::magicValue,也看到cacheValid的值是false,因此执行与第一个线程相同的昂贵计算。(“第二个线程”可能是其它好几个线程。)

这样的行为与缓存的目的背道而驰。对调cachedValue和cacheValid赋值语句可以消除这个问题,但结果依旧糟糕:

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函数,然后执行到cacheValid设置为true。

在这个时刻,第二个线程调用Widget::magicValue函数,然后检查cacheValid,看到它为true,尽管第一个线程还没赋值cachedValue,但该线程将cachedValue返回。因此返回的值是不正确的。

原因这里讲。如果只是一个变量或者一个存储单元需求同步,那么使用std::atomic就足够了,但是你有两个或者更多的变量和存储单元需要以一个单元的形式操作,你应该使用互斥锁。使用互斥锁的Widget::magicValue代码是这样的:

class Widget {
public:
...
int magicValue() const
{
std::lock_guard<std::mutex> guard(m);

if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
}
private:
mutable std::mutex m;
mutable int cachedValue;
mutable bool cacheValid{ false };
};


现在告诉你,本条款出以多线程会并发执行对象的const成员函数的设想为依据的。如果你写的const成员函数不是基于这种情况——你能保证不超过一个线程执行对象的成员函数——那么函数的线程安全无关紧要。例如,一些类的成员函数是专门为单个线程设计的,那么这种成员函数是否线程安全不重要,在这种情况下,你可以避免互斥锁和std::atomic的开销,以及它们带来的只能移动的副作用。不过,这种免疫线程的情况越来越不常见,它似乎还会慢慢减少。const成员函数十有八九会经受并发执行,那就是为什么你应该确保你的const成员函数是线程安全的。

总结

需要记住的2点:

const成员函数做到线程安全,除非你能肯定它们决不在并发语境中使用。

使用std::atomic变量可能比互斥锁提供更好的性能,不过它们只适用于单一变量和单一存储单元。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  c++
相关文章推荐