版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
线程之间共享数据的问题
(1)竞争条件
竞争条件
指多个线程或者进程在读写一个共享数据时结果依赖于它们执行的相对
时间
的情形。
竞争条件发生在当多个进程或者线程在读写数据时,其最终的的结果依赖于多个进程的指令执行顺序。
用互斥元保护共享数据
(1)C++中的互斥元
C++中,通过构造
std::mutex
的实例创建互斥元,调用成员函数
lock()
锁定互斥元,调用成员函数
unlock()
来解锁。
然而直接调用mutex是不推荐的做法,因为必须记住在离开函数的每条路径上都要记得unlock。
所以,标准C++库提供了
std::lock_guard
类模板锁,
实现了RAII的管用语法(构造时锁定给定互斥元,析构时解锁)
,从而保护被锁定的互斥元始终被正确的解锁。
mutex与lock_guard声明都在<mutex>头文件中。
-
std::
list<
int> some_list;
-
std::mutex some_mutex;
//全局的锁some_mutex
-
-
void add_to_list(int new_value)
-
{
-
std::lock_guard<
std::mutex> guard(some_mutex);
//构造时加锁
-
some_list.push_back(new_value);
-
//析构guard时解锁
-
}
-
bool list_contains(int value_to_find)
-
{
-
std::lock_guard<
std::mutex> guard(some_mutex);
//访问时也加锁
-
return
std::find(some_list.begin(), some_list.end(), value_to_find)
-
!= some_list.end();
-
}
(2)精心组织代码来保护共享数据
当其中一个成员函数返回的是保护数据的指针或引用时,会破坏对数据的保护。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制。互斥量保护的数据需要对接口的设计相当谨慎,要确保互斥量能锁住任何对保护数据的访问,并且不留后门。
-
class some_data
-
{
-
int a;
-
std::
string b;
-
public:
-
void do_something(){}
-
};
-
-
class data_wrapper
-
{
-
private:
-
some_data data;
-
std::mutex m;
-
public:
-
template<
typename Function>
-
void process_data(Function func)
-
{
-
std::lock_guard<
std::mutex> l(m);
-
func(data);
-
//可能会传出要保护的data的引用
-
//而传出后可能会发生对data的竞争条件,以至于m锁是失效的
-
}
-
};
-
-
some_data *unprotected;
-
void malicious_function(some_data &protected_data) //传递的是引用
-
{
-
unprotected = &protected_data;
-
}
-
-
data_wrapper x;
-
void foo()
-
{
-
x.process_data(malicious_function);
//传入一个恶意函数
-
unprotected->do_something();
//对于受保护的数据进行未受保护的访问
-
}
上述这种情况,是基于自己写的代码可能产生并发问题,不是C++标准库能接解决的。但是可以引出一个一般的原则:不要将受保护的指针和引用传递到锁的范围之外,无论是作为返回值还是作为参数传出。
(3)发现接口中内在的竞争条件
即使在一个很简单的接口中,依旧可能遇到条件竞争。例如,构建一个类似于std::stack结构的栈,除了构造函数和swap()以外,需要对std::stack提供五个操作:push()一个新元素进栈,pop()一个元素出栈,top()查看栈顶元素,empty()判断栈是否是空栈,size()了解栈中有多少个元素。即使修改了top(),使其返回一个拷贝而非引用(即遵循了3.2.2节的准则),对内部数据使用一个互斥量进行保护,不过这个接口仍存在条件竞争。这个问题不仅存在于基于互斥量实现的接口中,在无锁实现的接口中,条件竞争依旧会产生。这是接口的问题,与其实现方式无关。
-
template<typename T,typename Container=std::deque<T> >
-
class stack
-
{
-
public:
-
explicit stack(const Container&);
-
explicit stack(Container&& = Container());
-
template <
class Alloc> explicit stack(const Alloc&);
-
template <
class Alloc> stack(const Container&, const Alloc&);
-
template <
class Alloc> stack(Container&&, const Alloc&);
-
template <
class Alloc> stack(stack&&, const Alloc&);
-
-
bool empty() const;
-
size_t size() const;
-
T& top();
-
T
const& top()
const;
-
void push(T const&);
-
void push(T&&);
-
void pop();
-
void swap(stack&&);
-
};
-
-
stack<
int> s;
-
if (! s.empty()){
//可能进入if控制块后,其他线程就pop了最后一个元素
-
int
const value = s.top();
// 可能已经被其他线程pop()导致top()失败
-
s.pop();
//可能会发生对空栈的pop()
-
do_something(value);
-
}
(4)死锁:问题与解决方案
一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。这样没有线程能工作,因为他们都在等待对方释放互斥量。这种情况就是
死锁
,它的最大问题就是由两个或两个以上的互斥量来锁定一个操作。
避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。
但是,选择一个固定的顺序有时也会发生死锁,例如在参数交换了之后,两个线程试图在相同的两个实例间进行数据交换(导致锁的顺序也交换了)时,程序又死锁了。(先锁定(a锁,b锁),但是之后可能参数调换,导致锁定(b锁,a锁),从而引发死锁)。
C++标准库的解决方法是,
std::lock
,
可以一次性锁住多个互斥元
,并且没有死锁风险。
-
// 这里的std::lock()需要包含<mutex>头文件
-
class some_big_object;
-
void swap(some_big_object& lhs,some_big_object& rhs);
-
class X
-
{
-
private:
-
some_big_object some_detail;
-
std::mutex m;
-
public:
-
X(some_big_object
const& sd):some_detail(sd){}
-
-
friend void swap(X& lhs, X& rhs)
-
{
-
if(&lhs==&rhs)
-
return;
-
std::lock(lhs.m,rhs.m);
// 将二个锁同时上锁
-
std::lock_guard<
std::mutex> lock_a(lhs.m,
std::adopt_lock);
// 传入adopt_lock表示构造时就已经上锁
-
std::lock_guard<
std::mutex> lock_b(rhs.m,
std::adopt_lock);
// 3
-
swap(lhs.some_detail,rhs.some_detail);
-
}
-
};
lock要么将二个都锁住,要么一个都不锁。所以如果锁住第一个,在第二个上锁时发生异常,那么会放弃第一个锁,并且抛出异常。
(5)
当一个用户定义类型满足互斥元概念的三个函数:lock(),unlock(),try_lock()时,就可以将它用于std::lock_guard
。
(6)std::unique_lock——灵活的锁
std::unique_lock
实例
不会总有与自身相关的互斥量
,一个互斥量的所有权可以通过移动操作,在不同的实例中进行传递。所以可以将
std::defer_lock
作为第二个参数传递进去,表明构造时该互斥元保持解锁状态,这个锁可以在这之后通过将std::unique_lock对象本身传递给std::lock()来获取。
-
class some_big_object;
-
void swap(some_big_object& lhs,some_big_object& rhs);
-
class X
-
{
-
private:
-
some_big_object some_detail;
-
std::mutex m;
-
public:
-
X(some_big_object
const& sd):some_detail(sd){}
-
friend void swap(X& lhs, X& rhs)
-
{
-
if(&lhs==&rhs)
-
return;
-
std::unique_lock<
std::mutex> lock_a(lhs.m,
std::defer_lock);
// 1
-
std::unique_lock<
std::mutex> lock_b(rhs.m,
std::defer_lock);
// std::def_lock 留下未上锁的互斥量
-
std::lock(lock_a,lock_b);
// 互斥量在这里上锁
-
swap(lhs.some_detail,rhs.some_detail);
-
}
-
};
通过std::unique_lock实例中的标志,来确定该实例是否拥有特定的互斥量
,这个标志是为了确保unlock()在析构函数中被正确调用。如果实例拥有互斥量,那么析构函数必须调用unlock();但当实例中没有互斥量时,析构函数就不能去调用unlock()。这个标志可以通过
owns_lock()
成员变量进行查询。
同时,因为有这个标志的存在,std::unique_lock对象的体积通常要比std::lock_guard对象大,当使用std::unique_lock替代std::lock_guard,因为会对标志进行适当的更新或检查,就会做些轻微的性能惩罚。
保护共享数据的替代设施
互斥量是最通用的机制,但其并非保护共享数据的唯一方式。这里有很多替代方式可以在特定情况下,提供更加合适的保护。
例如双重锁定模式(单例模式中常见)
-
void undefined_behaviour_with_double_checked_locking()
-
{
-
if(!resource_ptr)
// 第一次筛选
-
{
-
std::lock_guard<
std::mutex> lk(resource_mutex);
-
if(!resource_ptr)
// 锁定后判断
-
{
-
resource_ptr.reset(
new some_resource);
// 访问
-
}
-
}
-
resource_ptr->do_something();
// 可能存在实例为空的情况
-
}
但是双重锁定模式存在一个竞争条件即使一个线程知道另一个线程完成对指针进行写入,它可能没有看到新创建的some_resource实例,然后调用do_something()后,得到不正确的结果(具体原因查询双重锁定模式的风险)。
C++标准委员会也认为条件竞争的处理很重要,所以C++标准库提供了
std::once_flag
和
std::call_once
来处理这种情况。比起锁住互斥量,并显式的检查指针,
每个线程只需要使用std::call_once,在std::call_once的结束时,就能安全的知道指针已经被其他的线程初始化
了。std::call_once可以和任何函数或可调用对象一起使用。
-
std::
shared_ptr<some_resource> resource_ptr;
-
std::once_flag resource_flag;
// 1
-
-
void init_resource()
-
{
-
resource_ptr.reset(
new some_resource);
-
}
-
-
void foo()
-
{
-
std::call_once(resource_flag,init_resource);
// 可以完整的进行一次初始化
-
resource_ptr->do_something();
-
}
值得注意的是,std::mutex和std::one_flag的实例就不能拷贝和移动,所以要定义移动构造等函数。
递归锁
在某些情况下,
一个线程尝试获取同一个互斥量多次
,而没有对其进行一次释放是可以的。之所以可以,是因为C++标准库提供了
std::recursive_mutex
类。互斥量锁住其他线程前,你必须释放你拥有的所有锁,所以当你调用lock()三次时,你也必须调用unlock()三次。
嵌套锁一般用在可并发访问的类上,所以其拥互斥量保护其成员数据。每个公共成员函数都会对互斥量上锁,然后完成对应的功能,之后再解锁互斥量。不过,有时成员函数会调用另一个成员函数,这种情况下,第二个成员函数也会试图锁住互斥量,这就会导致未定义行为的发生。“变通的”解决方案会将互斥量转为嵌套锁,第二个成员函数就能成功的进行上锁,并且函数能继续执行。