深入理解C++多线程中的数据共享与保护(c++ concurrency in action 第三章总结)
在现代C++中,多线程编程已经成为提高程序性能的关键技术之一。然而,与此同时,处理多线程程序中的共享数据也带来了许多新的挑战。如何确保数据在多线程环境下的安全访问,以及如何避免常见的多线程问题如死锁和竞争条件,是每个C++开发者必须面对的问题。本文将结合《c++ concurrency in action》一书的第三章内容,深入探讨这些问题并给出相应的解决方案。
1. 共享数据的问题与竞争条件
在多线程编程中,共享数据的安全性是一个至关重要的问题。当多个线程需要同时访问或修改同一数据时,往往会引发竞争条件(Race Condition)。这就像两个孩子同时想要玩一个玩具,如果没有明确的使用规则,他们很可能会因为争抢玩具而发生冲突。
举个例子,假设我们有一个简单的链表,如果一个线程正在删除链表中的某个节点,而另一个线程恰好在遍历链表,那么第二个线程很可能会遇到链表结构不完整的情况,导致程序崩溃或数据错误。这种情况下,链表的完整性在删除操作过程中被破坏,从而引发了竞争条件。
为了避免这种问题,我们通常需要使用**互斥锁(std::mutex)**来保护共享数据,确保同一时刻只有一个线程能够访问或修改这些数据。然而,使用锁虽然能解决数据一致性问题,却可能引发另一个棘手的问题——死锁。
#include <iostream>
#include <list>
#include <mutex>
#include <thread>
std::mutex mut;
std::list<int> lis;
void add_to_list(int value) {
std::lock_guard<std::mutex> guard(mut); // 使用lock_guard自动管理锁的开闭
lis.push_back(value);
}
bool list_contains(int value_to_find) {
std::lock_guard<std::mutex> guard(mut);
return std::find(lis.begin(), lis.end(), value_to_find) != lis.end();
}
int main() {
std::thread t1([] { add_to_list(1); });
std::thread t2([] { add_to_list(2); });
std::thread t3([] { std::cout << std::boolalpha << list_contains(1) << std::endl; });
t1.join();
t2.join();
t3.join();
for (auto const& ele : lis) {
std::cout << ele << std::endl;
}
return 0;
}
2. 死锁与如何避免
死锁(Deadlock)通常发生在两个或多个线程彼此等待对方释放资源的情况下,导致所有线程都无法继续执行。一个典型的例子是,两个线程分别锁住了两个互斥锁,并且都在等待对方释放锁。由于双方都不释放锁,结果导致程序陷入僵局。
为了避免死锁,C++标准库提供了std::lock函数。这个函数可以一次性锁定多个互斥锁,避免线程在锁定多个资源时发生死锁。此外,C++17引入了std::scoped_lock,它可以更方便地处理多个锁的同时锁定。
在实际应用中,我们还可以通过以下几种方式避免死锁:
- 避免嵌套锁定:尽量避免在已经持有一个锁的情况下再去锁定另一个锁。如果必须这么做,可以使用std::lock来确保锁定的顺序一致。
- 避免在持有锁时调用用户代码:调用外部代码时,无法控制其行为,可能导致其试图锁定其他资源,从而引发死锁。
- 锁定顺序一致:在多个线程中锁定资源时,确保所有线程以相同的顺序锁定资源。
下面的代码展示如何按一定的层次顺序锁定,value大的锁先于value小的上锁,解锁时相反
#include <mutex>
#include <stdexcept>
#include <thread>
class hierarchical_mutex {
public:
explicit hierarchical_mutex(unsigned long value) : hierarchy_value(value), previous_hierarchy_value(0) {}
void lock() {
check_for_hierarchy_violation();
internal_mutex.lock();
update_hierarchy_value();
}
void unlock() {
if (this_thread_hierarchy_value != hierarchy_value) {
throw std::logic_error("mutex hierarchy violated");
}
this_thread_hierarchy_value = previous_hierarchy_value;
internal_mutex.unlock();
}
bool try_lock() {
check_for_hierarchy_violation();
if (!internal_mutex.try_lock()) {
return false;
}
update_hierarchy_value();
return true;
}
private:
std::mutex internal_mutex;
unsigned long const hierarchy_value;
unsigned long previous_hierarchy_value;
static thread_local unsigned long this_thread_hierarchy_value;
void check_for_hierarchy_violation() const {
if (this_thread_hierarchy_value <= hierarchy_value) {
throw std::logic_error("mutex hierarchical violated");
}
}
void update_hierarchy_value() {
previous_hierarchy_value = this_thread_hierarchy_value;
this_thread_hierarchy_value = hierarchy_value;
}
};
hierarchical_mutex high_level_mutex(10000);
hierarchical_mutex low_level_mutex(5000);
hierarchical_mutex other_mutex(6000);
int do_low_level_stuff();
int low_level_func() {
std::lock_guard<hierarchical_mutex> lk(low_level_mutex);
return do_low_level_stuff();
}
void high_level_stuff(int some_param);
void high_level_func() {
std::lock_guard<hierarchical_mutex> lk(high_level_mutex);
high_level_stuff(low_level_func());
}
void thread_a() {
high_level_func();
}
void do_other_stuff();
void other_stuff() {
high_level_func();
do_other_stuff();
}
void thread_b() {
std::lock_guard<hierarchical_mutex> lk(other_mutex);
//错误的,含有value为6000锁的线程不能锁上value为10000的
other_stuff();
}
3. 递归锁std::recursive_mutex
有些情况下,同一个线程可能需要多次锁定同一个互斥锁。此时,如果使用普通的std::mutex,将会导致死锁。std::recursive_mutex允许线程多次锁定同一个互斥锁,直至显式释放所有的锁。
#include <iostream>
#include <mutex>
#include <thread>
std::recursive_mutex rec_mutex;
void recursive_function(int count) {
if (count < 1) return;
std::lock_guard<std::recursive_mutex> guard(rec_mutex);
std::cout << "Recursion level: " << count << std::endl;
recursive_function(--count);
}
int main() {
std::thread t1(recursive_function, 5);
t1.join();
return 0;
}
}
4. 线程安全的栈:threadsafe_stack
设计线程安全的数据结构时,使用std::mutex和std::lock_guard的组合非常常见。下面是一个线程安全栈的实现,它通过std::mutex来保护内部数据结构,防止竞态条件的发生。
#include <exception>
#include <memory>
#include <mutex>
#include <stack>
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() = default;
threadsafe_stack(const threadsafe_stack& other) {
std::lock_guard<std::mutex> lock(other.m);
data = other.data;
}
void push(T new_value) {
std::lock_guard<std::mutex> lock(m);
data.push(std::move(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;
}
bool empty() const {
std::lock_guard<std::mutex> lock(m);
return data.empty();
}
};
5. 使用std::unique_lock进行灵活锁定
std::unique_lock提供了比std::lock_guard更灵活的锁定方式。它可以延迟锁定、提前解锁,并支持锁的所有权转移。
#include <mutex>
#include <thread>
std::mutex m;
void process_data() {
std::unique_lock<std::mutex> lk(m);
// 处理数据
lk.unlock(); // 提前解锁以提高并发性
// 进一步处理无需保护的数据
lk.lock(); // 重新加锁
}
int main() {
std::thread t1(process_data);
std::thread t2(process_data);
t1.join();
t2.join();
return 0;
}
6.锁的粒度:如何选择适当的锁定范围
在多线程编程中,锁的粒度是指锁定的资源范围以及锁定的持续时间。选择合适的锁粒度是编写高效并发程序的关键。锁的粒度太粗会导致不必要的线程阻塞,降低并发性;而锁的粒度太细则可能增加死锁的风险,或因频繁的锁操作带来性能开销。因此,平衡锁的粒度非常重要。
锁的粒度和代码示例
以下是一个使用std::unique_lock来管理锁粒度的示例:
#include <mutex>
#include <thread>
std::mutex m;
void process_data() {
std::unique_lock<std::mutex> lk(m);
// 处理需要保护的数据
// 由于锁住了整个互斥量,其他线程会在此处阻塞,直到解锁。
lk.unlock(); // 提前解锁以提高并发性
// 进一步处理无需保护的数据
// 此处操作不需要持有锁,因此可以并发执行,从而提高程序的效率。
lk.lock(); // 重新加锁以处理需要保护的数据
// 再次操作受保护的数据
}
int main() {
std::thread t1(process_data);
std::thread t2(process_data);
t1.join();
t2.join();
return 0;
}
锁的粒度讨论
在上面的代码中,process_data函数展示了如何通过调整锁的持有时间来控制锁的粒度。该函数执行了以下几个步骤:
-
获取锁并处理受保护的数据:开始时,函数锁定了互斥量m,并对受保护的数据进行了处理。这是典型的锁定模式,用于防止多个线程同时修改共享资源。
-
提前解锁以提高并发性:完成受保护数据的操作后,函数立即解锁。这一解锁操作使得其他线程能够尽早获得锁,从而提高了整个程序的并发性能。
-
处理无需保护的数据:在解锁状态下,函数继续处理那些不需要保护的数据。由于这些操作不依赖于共享资源,因此可以并发执行。
-
重新加锁以处理更多受保护的数据:在需要再次访问共享资源时,函数重新加锁。这确保了对共享数据的访问始终是安全的。
通过这样做,我们将锁的粒度控制在一个合理的范围内:即只在真正需要时才持有锁。这种策略可以减少不必要的线程阻塞,提高程序的并发性和性能。