第三章 共享数据
3.1 共享数据的问题
- 不变量(invariant):关于一个特定数据结构中总为
true
的语句,比如双向链表的两个相邻节点A、B,A的后指针一定指向B,B的前指针一定指向A
。有时候程序会暂时破坏不变量,通常发生在更新复杂数据结构的时候,比如删除双向链表中的一个节点N,需要让N的前一个节点指向N的后一个节点
,不变量破坏。 - 条件竞争race condition):线程修改共享数据的时候,就会发生破坏不变量的情况,如果此时有其他线程访问修改,就可能导致数据结构永久破坏。
- 数据竞争(data race):C++规定线程之间并发的修改单个对象为特定的一种条件竞争。会造成未定义的行为。
3.2 使用互斥量
3.2.1 互斥量
-
访问共享数据前,将数据上锁,访问结束后,在解锁。其他线程必须等待数据解锁之后,才能访问。
-
C++通过实例化
std::mutex
创建互斥量实例,成员函数lock()\unlock()
可对互斥量上锁\解锁。 -
但是一般不直接调用这两个函数,而是使用
std::lock_guard
来自动处理加锁与解锁。它在构造的时候接受一个mutex
并调用mutex.lock()
,析构的自动调用mutex.unlock()
。
#include <iostream>
#include <mutex>
class A {
public:
void lock() { std::cout << "lock" << std::endl; }
void unlock() { std::cout << "unlock" << std::endl; }
};
int main() {
A a;
{
std::lock_guard<A> l(a); // lock
} // unlock
}
- 一般
mutex
和要保护的数据一起放在类中,定义为private
数据成员,而非全局变量,这样能让代码更清晰。但如果某个成员函数返回指向数据成员的指针或引用,则通过这个指针的访问行为不会被mutex
限制,因此需要谨慎设置接口,确保mutex
能锁住数据
#include <mutex>
class A {
public:
void f() {}
};
class B {
public:
A* get_data() {
std::lock_guard<std::mutex> l(m_);
return &data_;
}
private:
std::mutex m_;
A data_;
};
int main() {
B b;
A* p = b.get_data();
p->f(); // 未锁定 mutex 的情况下访问数据
}
3.2.2 接口之间的条件竞争
- 即便在很简单的接口中,也可能遇到
race condition
std::stack<int> s;
if (!s.empty()) {
int n = s.top(); // 此时其他线程 pop 就会获取错误的 top
s.pop();
}
-
上述代码先检查非空再获取栈顶元素,在单线程中是安全的,但在多线程中,检查非空之后,如果其他线程先
pop
,就会导致当前线程top
出错。另一个潜在的竞争是,如果两个线程都未pop
,而是分别获取了top
,虽然不会产生未定义行为,但这种对同一值处理了两次的行为更为严重,因为看起来没有任何错误,很难定位 bug。 -
这个错误,即使使用了互斥量,对栈内部数据进行保护也无法阻止条件竞争的发生,原因在于接口设计的问题,接口过多,在一个线程调用接口之间,可能有其他线程也进行了操作。
-
为何不直接使用
pop()
返回元素,而需要拆成top()
与pop()
?原因在于构造返回值的时候可能抛出异常,使元素弹出了,但是未返回造成数据丢失。- 假设有一个元素为
vector
的stack
,并且pop()
可以返回栈顶元素。 - ”返回“这个操作肯定在函数的最后一步,这之前元素已经弹出,但是拷贝
vector
需要在堆上分配空间,如果系统负载严重导致内存空间不足无法分配,vector
的构造函数就会抛出bad_alloc
异常,导致返回失败,但是数据已经丢失
- 假设有一个元素为
-
下面思考如何将
pop()
与top()
合为一步。- 方法一:传入一个引用来获取结果值
- 这样可以避免在获取之前弹出元素,保证可以成功获取元素之后,在弹出元素。
- 但是缺点也很明显,需要提前构造一个栈中元素类型的实例,用于接收目标值,太麻烦,而且有时候构造还需要参数,而且还要是可以赋值的存储类型。
std::vector<int> res; s.pop(res);
- 方法二:无异常抛出的拷贝构造或者移动构造函数
- 对于有返回值的
pop()
函数来说,只有“异常安全”方面的担忧(当返回值时可以抛出一个异常)。 - 但这种方式过于局限,只支持拷贝或移动不抛异常的类型
- 对于有返回值的
- 方法三:返回指向弹出值的指针
- 指针可以自由拷贝,而且不会抛出异常。
- 缺点就是返回指针需要对对象的内存分配进行管理,对于简单数据类型(比如:int),内存管理的开销要远大于直接返回值。
- 对于这个方案,使用
std::shared_ptr
是个不错的选 择,不仅能避免内存泄露(因为当对象中指针销毁时,对象也会被销毁),而且标准库能够完全控制内存分配方案,就不需要new和delete操作。
- 方法四:结合方法一和二 或者 结合方法一和三
#include <iostream> #include <thread> #include <mutex> #include <stack> #include <memory> #include <exception> using namespace std; struct empty_stack : std::exception { const char* what() const throw() { return "empty stack"; }; }; template<typename T> class threadsafe_stack { private: stack<T> data; mutable mutex m; public: threadsafe_stack() : data(stack<T>()) {} threadsafe_stack(const threadsafe_stack& other) { lock_guard<mutex> lock(other.m); data = other.data; } threadsafe_stack& operator=(const threadsafe_stack& other) = delete; void push(T new_value) { lock_guard<mutex> lock(m); data.push(new_value); } shared_ptr<T> pop() { lock_guard<mutex> lock(m); if (!data.empty()) { throw empty_stack(); } shared_ptr<T> const res(make_shared<T>(data.top())); data.pop(); return res; } void pop(T& value) { lock_guard<mutex> lock(m); if (!data.empty()) { throw empty_stack(); } value = data.top(); data.pop(); } bool empty() const { lock_guard<mutex> lock(m); return data.empty(); } }; int main() { }
- 削减接口,以保证最大的线程安全。
- 方法一:传入一个引用来获取结果值
3.2.3 死锁
- 死锁的四个必要条件:
- 互斥
- 不可抢占
- 请求和保持
- 循环等待
- 线程对锁有竞争,当一个线程需要多个锁,对他们的互斥量进行操作时,如果另一个线程也有这样的需求,一人占有了一个锁,并且等待对方手中的锁,就会出现死锁,导致线程无法继续推进。
- 避免死锁就是避免四个死锁的条件,通常建议,对两个及以上的
mutex
,都以相同的顺序进行上锁,但是这不是所有情况都适用;- 有多个互斥量保护同一个类的 独立实例时,一个操作对同一个类的两个不同实例进行数据的交换操作,为了保证数据交换操作的正确性, 就要避免并发修改数据,并确保每个实例上的互斥量都能锁住自己要保护的区域。不过,选择一个固定的顺 序(例如,实例提供的第一互斥量作为第一个参数,提供的第二个互斥量为第二个参数),可能会适得其反:在 参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了。
- 但是标准库中的
lock()
可以一次性锁住多个互斥量,并且没有副作用(死锁风险)。
#include <mutex>
#include <thread>
using namespace std;
struct A {
explicit A(int n) : n_(n) {}
mutex m_;
int n_;
};
void _swap(A& a, A& b) {
if (&a == &b) {
return; // 防止对同一对象重复加锁
}
std::lock(a.m_, b.m_); // 同时上锁防止死锁
// 下面按固定顺序加锁,看似不会有死锁的问题
// 但如果没有 std::lock 同时上锁,另一线程中执行 f(b, a, n)
// 两个锁的顺序就反了过来,从而可能导致死锁
std::lock_guard<std::mutex> lock1(a.m_, std::adopt_lock);
std::lock_guard<std::mutex> lock2(b.m_, std::adopt_lock);
// 等价实现,先不上锁,后同时上锁
// std::unique_lock<std::mutex> lock1(a.m_, std::defer_lock);
// std::unique_lock<std::mutex> lock2(b.m_, std::defer_lock);
// std::lock(lock1, lock2);
int temp = a.n_;
a.n_ = b.n_;
b.n_ += temp;
}
int main() {
A x{ 70 };
A y{ 30 };
thread t1(_swap, std::ref(x), std::ref(y));
thread t2(_swap, std::ref(y), std::ref(x));
t1.join();
t2.join();
}
- 避免死锁的建议
- 避免嵌套锁:一个线程已经获取一个锁时就不要获取第二个。如果每个线程只有一个锁,锁上就不会产生死锁(但除了互斥锁,其他方面也可能造成死锁,比如即使无锁,线程间相互等待也可能造成死锁)
- 避免在持有锁的时候调用外部函数:因为代码是外部提供的,所以没有办法确定外部要做什么。外部程序可能做任何事 情,包括获取锁。
- 使用固定顺序获取锁:当硬性要求获取两个或两个以上的锁,并且不能使用 std::lock 单独操作来获取它们时,最好在每个线程上, 用固定的顺序获取它们(锁)。
- 使用层级锁
- 层级锁(Hierarchical Locks)是一种多线程同步机制,旨在解决多线程编程中的死锁问题。它的核心思想是引入锁的层级结构,并规定线程只能按照一定的顺序获取锁,从而避免死锁的发生。
- 层级锁引入了锁的层级结构,每个锁都被分配到一个特定的层级。线程只能按照升序或降序的顺序获取锁,即从低层级锁到高层级锁或反之。这确保了线程不会形成循环等待。
3.2.4 灵活的锁——std::unique_lock
std::unique_lock
是C++标准库中的一个类,用于管理互斥量(std::mutex
)的锁定和解锁操作。它提供了比std::lock_guard
更灵活的锁定机制,允许您在需要时手动锁定和解锁互斥量。
- 构造函数:
std::unique_lock<std::mutex> lock(mutex);
:创建一个std::unique_lock
对象lock
并立即锁定互斥量mutex
。std::unique_lock<std::mutex> lock(mutex, std::defer_lock);
:创建一个std::unique_lock
对象lock
,但不会立即锁定互斥量mutex
,而是延迟锁定。std::unique_lock<std::mutex> lock(mutex, std::try_to_lock);
:尝试锁定互斥量mutex
,如果锁定成功则创建lock
,否则创建一个未锁定的lock
对象。std::unique_lock<std::mutex> lock(mutex, std::adopt_lock);
:假设当前线程已经锁定了互斥量mutex
,然后创建一个lock
对象来接管已有的锁。
- 成员函数:
lock()
:手动锁定互斥量。如果std::unique_lock
对象是延迟锁定的,可以使用此方法来锁定互斥量。unlock()
:手动解锁互斥量。可以使用此方法来显式释放互斥量的锁定。try_lock()
:尝试锁定互斥量,如果互斥量已被其他线程锁定,则不会阻塞当前线程,而是返回一个指示锁定状态的布尔值。
- 自动析构解锁:
std::unique_lock
对象的析构函数会自动解锁互斥量,这意味着当std::unique_lock
对象超出其作用域时,互斥量会被自动解锁,确保不会忘记解锁。 - 锁定和解锁的灵活性:与
std::lock_guard
不同,std::unique_lock
允许您手动控制锁定和解锁的时机,使其更适用于复杂的多线程场景。例如,您可以在需要时释放锁定,并在稍后重新锁定互斥量,以允许其他线程执行某些操作。 - 条件变量的配合:
std::unique_lock
常常与条件变量(std::condition_variable
)一起使用,用于等待某些条件的发生,然后在条件满足时唤醒。条件变量需要与std::unique_lock
一起使用,以确保在等待期间互斥量处于解锁状态。
- 总之,
std::unique_lock
提供了更多的灵活性和控制,适用于需要手动控制互斥量锁定和解锁时机的多线程编程场景。它是实现复杂多线程同步的重要工具之一。
3.2.5 读写锁
- 读写锁(
Read-Write Lock
),也称为共享-独占锁,是一种多线程同步机制,用于管理对共享资源的并发访问。与标准互斥量不同,读写锁允许多个线程同时读取共享资源,但在写入共享资源时只能有一个线程。这种机制旨在提高多线程程序的性能,因为通常情况下,读取操作不会修改共享资源,所以允许多个线程同时读取不会引发竞争条件。
1. 读写锁的类型
在C++中,读写锁通常有两种类型:读锁(Read Lock
)和写锁(Write Lock
)。
- 读锁:允许多个线程同时持有读锁,以允许并发读取操作,但阻止写入操作。只有在没有写锁的情况下才能获取读锁。
- 写锁:只允许一个线程持有写锁,以确保写入操作的独占性。在有写锁或读锁的情况下,无法获取写锁。
2. 使用场景
读写锁适用于以下场景:
- 当共享资源在读取时被多个线程频繁访问,但在写入时只有一个线程修改时,使用读写锁可以提高并发性。
- 读写锁特别适用于对共享资源的读取操作比写入操作频繁的情况,因为它允许多个线程同时读取。
3. 读写锁的优点
- 提高性能:允许多个线程同时读取,提高了并发性,因为读取操作通常不会修改数据。
- 写入保护:写锁提供了对写入操作的互斥保护,确保只有一个线程可以修改共享资源。
#include <iostream>
#include <shared_mutex>
#include <thread>
using namespace std;
shared_mutex rwLock;
int shared_int = 10;
void reader() {
shared_lock<shared_mutex> lock(rwLock);
cout << "Read: " << shared_int << endl;
}
void writer() {
unique_lock<shared_mutex> lock(rwLock);
shared_int += 10;
cout << "Write: " << shared_int << endl;
}
int main() {
thread t_1(reader);
thread t_2(reader);
thread t_3(writer);
t_1.join();
t_2.join();
t_3.join();
}
3.3 保护共享数据的方式
3.3.1 保护共享数据的初始化
- 延迟初始化
(Lazy initialization)
在单线程代码很常见——每一个操作都需要先对源进行检查,为了了解数据是 否被初始化,然后在其使用前决定,数据是否需要初始化。 - 只有①处需要保护,这样共享数据对于并发访问就是安全的。但是下面天真的转换会使 得线程资源产生不必要的序列化,为了确定数据源已经初始化,每个线程必须等待互斥量。
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex;
void foo() {
std::unique_lock<std::mutex> lk(resource_mutex); // 所有线程在此序列化
if (!resource_ptr) {
resource_ptr.reset(new some_resource); // 只有初始化过程需要保护 ①
}
lk.unlock();
resource_ptr->do_something();
}
- C++11 提供了
std::once_flag
和std::call_once
来保证对某个操作只执行一次
#include <memory>
#include <mutex>
#include <thread>
using namespace std;
class A {
public:
void fun() {}
};
shared_ptr<A> p;
once_flag once_flag_t;
void init() {
call_once(once_flag_t, [] {p = make_shared<A>();});
p->fun();
}
int main() {
thread t_1(init);
thread t_2(init);
t_1.join();
t_2.join();
}
std::once_flag
和std::call_once
也可以用在类中,表示这个实例化对象只调用一次
#include <memory>
#include <mutex>
#include <iostream>
#include <thread>
using namespace std;
class A {
public:
void fun() {
call_once(m_once, &A::print, this);
}
private:
once_flag m_once;
void print() const {
cout << 1 << endl;
}
};
int main() {
A a;
thread t_1(&A::fun, &a);
thread t_2(&A::fun, &a);
t_1.join();
t_2.join();
}
- 在 C++11 及以后的标准中,静态局部变量的初始化是线程安全的。所以懒汉模式的初始化可以使用static 局部变量
class my_class {
public:
static my_class& get_instance() {
static my_class instance; // 在程序启动时创建实例
return instance;
}
void do_something() {
// 实例方法的实现
}
private:
my_class() {
// 私有构造函数,防止外部实例化
}
// 阻止复制构造函数和赋值操作符
my_class(const my_class&) = delete;
my_class& operator=(const my_class&) = delete;
};