0%

CPP并发_03线程间共享数据

C++ 并发编程学习03

  • 共享数据带来的问题

  • 使用互斥量保护数据

  • 数据保护的替代方案

3.1 共享数据带来的问题

修改共享数据,致使不变量遭到了破坏,可能会造成并行中常见的错误: 条件竞争(race condition)

并发中 条件竞争 的形成,取决于一个以上的线程的相对执行顺序,每个线程都抢着完成自己的任务。并发中对数据的条件竞争通常表示为 恶性条件竞争

避免恶性条件竞争

避免 恶性条件竞争,最简单的办法就是对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。

另一个选择是对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程。

另一种处理条件竞争的方式是,使用事务的方式去处理数据结构的更新。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交

3.2 使用互斥量保护共享数据

当访问共享数据前,将数据锁住,在访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,都必须等到之前那个线程对数据进行解锁后才能访问,保证了所有线程可以看到共享数据,而不破坏不变量。

3.2.1 使用互斥量

通过实例化std::mutex创建 互斥量实例 ,通过 成员函数 lock()对互斥量上锁,unlock()进行解锁。

C++标准库为互斥量提供了一个 RAII语法模板类 std::lock_guard,在 构造 时就能提供已锁的互斥量,并在 析构 的时候进行解锁,从而保证了一个已锁互斥量能被正确解锁。

将互斥量和需要保护的数据放在同一类中时, 当其中一个成员函数 返回 的是保护数据的指针或引用时,会破坏数据 。具有访问能力的指针或引用可以访问(并可能修改)被保护的数据,而不会被互斥锁限制,所以要确保互斥量能锁住数据的访问,并且 不留后门

3.2.2 用代码来保护共享数据

使用 互斥量 来保护数据,并不是仅仅在每一个成员函数中都加入一个std::lock_guard对象那么简单,一个 指针或引用 ,也会让这种保护形同虚设。在确保成员函数 不会传出 指针或引用的同时,检查成员函数是否通过指针或引用的方式来调用也很重要。

3.2.3 定位接口间的条件竞争

STL中stack类是线程不安全的,当你模仿着想写一个属于自己的线程安全的类Stack时,你在 push 和 pop 等操作得时候,加了互斥锁保护数据。但是在多线程环境下使用使用你的Stack类的时候,却仍然有可能是线程不安全的,为什么?

假设Stack类的接口如下:

1
2
3
4
5
6
7
8
9
10
11
class Stack
{
public:
Stack() {}
void pop(); //弹出栈顶元素
int& top(); //获取栈顶元素
void push(int x);//将元素放入栈
private:
vector<int> data;
std::mutex _mu; //保护内部数据
};

类中的每个函数都是线程安全的,但是 组合起来却不是。加入栈中有 9,3,8,6 四个元素,现在使用两个线程分别取出栈中的元素进行处理,如下:

ThreadA ThreadB
int v = st.top() //6
int v = st.top() //6
st.pop(); //弹出6
st.pop(); //弹出8
process(v);//处理6
process(v);//处理6

可以发现在这种执行顺序下, 栈顶元素被处理了两遍,而且多弹出了一个元素 8 ,导致 8 没有被处理!这就是由于接口设计不当引起的竞争。

解决办法:

选项1. 传入一个引用

选项2. 无异常抛出的拷贝构造函数或移动构造函数

选项3. 返回指向弹出值的指针

选项4. 1+2 或 1 + 3

解决方案1: 将两个接口合并为一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Stack
{
public:
Stack() {}
int& pop(); //弹出栈顶元素并返回
void push(int x);//将元素放入栈
private:
vector<int> data;
std::mutex _mu; //保护内部数据
};

//下面这样使用就不会发生问题
int v = st.pop(); // 6
process(v);

但是这样修改之后虽然是 线程安全 的,但是 并不是异常安全 的。这也是为什么STL中栈的出栈操作分解成了两个步骤的原因。参考

具体这个 异常安全 问题:如果向上面的代码那样,将 pop() 函数的的“弹出值”同时作为返回值返回到调用函数时,调用函数抛出了一个异常会怎么样?因为在将返回值返回到调用函数时发生了内存的拷贝,但是不能保证每次拷贝都是成功的,一旦失败,那么“弹出值”已经从栈上移除了,这样即没得到这个返回值,还丢失了原来栈上的值。这也就是为什么std::stack将这个操作分成了两步:先获取顶部元素(top()), 然后再从栈中移除(pop()),这样就在 不能安全的将元素拷贝出去的情况下,栈中的数据还依旧存在,没有丢失。来源

解决方案2——线程安全的栈:传入一个引用或者返回指向弹出值的指针(选项一 + 选项三)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <exception>
#include <stack>
#include <mutex>
#include <memory>
#include <iostream>

struct empty_stack : std::exception
{
const char* what() const throw()
{
return "empty stack";
}
};

template<typename T>
class threadsafe_stack
{
private:
std::stack<T> data;
mutable std::mutex m;
public:
threadsafe_stack() {}
threadsafe_stack(const threadsafe_stack& other) //拷贝构造函数
{
std::lock_guard<std::mutex> lock(other.m); // 使用互斥量确保复制结果的正确性
data = other.data;
}
threadsafe_stack& operator=(const threadsafe_stack&) = delete; //栈不能直接返回,所以删除赋值操作

void push(T new_value)
{
std::lock_guard<std::mutex> lock(m);
data.push(new_value);
}

std::shared_ptr<T> pop()
{
std::lock_guard<std::mutex> lock(m); //相当于锁住了这个作用域,避免其他线程的竞争
if (data.empty()) throw empty_stack();
std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
data.pop();
return res;
}
void pop(T& value) // 将变量的引用作为参数
{
std::lock_guard<std::mutex> lock(m); //相当于锁住了这个作用域,避免其他线程的竞争
if (data.empty()) throw empty_stack();
value = data.top();
data.pop();
}
bool empty() const
{
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};

int main()
{
threadsafe_stack<int> si;
si.push(5);
//选项三:返回一个指向弹出元素的指针,而不是直接返回值,指针的优势是自由拷贝,且不会产生异常
//缺点是返回一个指针需要对对象的内存分配进行管理,对于简单的数据类型(比如:int),内存管理的开销远大于直接返回值
//使用 std::shared_ptr 不仅能避免内存泄漏,且标准库能完全控制内存分配方案
//std::shared_ptr<int> ptr = si.pop();
std::cout << *si.pop() << std::endl; //输出5
if (!si.empty())
{
std::cout << "stack is not empty" << std::endl;
//选项一: 传入一个引用
// 但是,构造一个栈中类型的实例,用于接收目标值,从时间和资源的角度看都是不划算的
//对于其他类型一不一定可行,因为构造函数需要的一些参数,在这个阶段的代码不一定可用
// 很多用户自定义类型可能都不支持赋值操作
int x;
si.pop(x); // 因为传入的 x 是引用
std::cout << x << std::endl; // 这样也可以得到 5
}
return 0;
}

3.2.4 死锁

3.2.5 避免死锁的指导

1. 避免嵌套锁

2. 避免在持有锁时调用用户提供的代码

3. 使用固定顺序获取锁

4. 使用锁的层次结构