C++并发编程(2)——线程间共享数据

本篇博客讲述如何在多个线程之间共享数据,并且在各种情况下使用互斥量来防止线程之间对数据竞争访问。

互斥量

互斥量的使用很简单,所见即所得:

1
2
3
std::mutex some_mutex;
some_mutex.lock();
some_mutex.unlock();

但是通过这样的方法使用互斥量有点麻烦,因为这意味着需要在函数的每个出口,包括异常处理中调用 unlock()。一个更加 RAII 的方法是使用模板类 std::lock——guard,它就像智能指针一样,在自己的生存期结束时自动释放锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ThreadSafeList {
private:
std::list<int> data_list; // 需要保护的数据
std::mutex data_mutex; // 关联的互斥量

public:
// 向列表中添加数据(线程安全)
void add(int value) {
std::lock_guard<std::mutex> lock(data_mutex);
data_list.push_back(value);
}
// 检查列表中是否存在某值(线程安全)
bool contains(int value) {
std::lock_guard<std::mutex> lock(data_mutex);
return std::find(data_list.begin(), data_list.end(), value) != data_list.end();
}
};

数据引用的传递

但是一定要注意一个潜在的陷阱,不应该在使用 lock_guard 加锁的函数中奖数据的引用传递给其他函数,导致未知的后果。

1
2
3
4
5
6
template<typename Function>
void process_data(Function func)
{
std::lock_guard<std::mutex> l(m);
func(data); // 1 传递“保护”数据给用户函数
}

设计线程安全的类

在多线程环境中,哪怕很细微的操作都需要注意线程安全。这一小节以设计一个线程安全的栈为例子讲解线程安全的设计模式。
在设计之前,首先需要明确哪些接口会引发竞争条件。在栈这个例子中,最主要的竞争场景就是 toppop 方法对栈上数据访问的竞争。
面对这一挑战,首先需要考虑的是削减接口,以获得最大程度的安全;其次,针对栈这个场景,必须保证在读取栈上数据的同时弹出数据。下面是三个可行的编程实践:

方法1:传入一个引用

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
#include <memory>
#include <mutex>
#include <stack>
#include <exception>

template<typename T>
class ThreadSafeStack {
private:
std::stack<T> data_stack;
mutable std::mutex mtx;

public:
// 压入元素(线程安全)
void push(T value) {
std::lock_guard<std::mutex> lock(mtx);
data_stack.push(std::move(value));
}
// 弹出元素到引用参数(避免拷贝)
void pop(T& value) {
std::lock_guard<std::mutex> lock(mtx);
if (data_stack.empty()) throw std::runtime_error("Stack is empty!");
value = data_stack.top();
data_stack.pop();
}
// 判断栈是否为空(线程安全)
bool empty() const {
std::lock_guard<std::mutex> lock(mtx);
return data_stack.empty();
}
};

方法2:使用智能指针

1
2
3
4
5
6
7
8
std::shared_ptr<T> pop() {
std::lock_guard<std::mutex> lock(mtx);
if (data_stack.empty()) return nullptr; // 栈空返回空指针
// 在弹出前创建返回对象的副本(异常安全)
auto res = std::make_shared<T>(data_stack.top());
data_stack.pop();
return res;
}

方法3:采用移动语义

1
2
3
4
5
6
7
T pop() {
std::lock_guard<std::mutex> lock(mtx);
if (data_stack.empty()) throw std::runtime_error("Stack is empty!");
T value = std::move(data_stack.top()); // 移动语义减少拷贝
data_stack.pop();
return value;
}

大前提是对象的拷贝构造函数或移动构造函数不抛出异常,否则 pop 方法不执行,将导致数据不同步。另一个前提是栈中储存的对象支持移动语义,否则仍需要进行内存的复制,性能低下。

避免死锁的方法

避免死锁的方法有很多,例如按顺序获取锁等等,这些都是“道”,这一节主要讲避免死锁的两种编程“法”。两种方法的思路都是一样的,就是一次性锁定所有的互斥量。

采用 std::adopt_lock

1
2
3
4
5
6
7
8
9
10
11
12
std::mutex mtx1, mtx2;

void thread_work() {
// 一次性锁定多个互斥量
std::lock(mtx1, mtx2);

// 使用 adopt_lock 表示锁已被当前线程持有
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);

// 临界区操作
}

std::lock_guard 的第二个参数 std::adopt_lock 主要是用于表面,当前的这个锁已经被获得了,此次调用不需要再次上锁,只需要在生命周期结束时释放就行。

采用 std::unique_lock + std::defer_lock

1
2
3
4
5
6
7
8
9
10
11
12
13
std::mutex mtx1, mtx2;

void thread_work() {
// 创建 unique_lock 但不立即锁定(defer_lock)
// defer_lock表示延迟加锁
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);

// 一次性锁定所有互斥量
std::lock(lock1, lock2);

// 临界区操作
}

lock_guardunique_lock 的区别就是,后者控制起来更灵活,可以在生命期内多次加锁和解锁,但是相应的,所需要的存储空间更大,时间也更多。
也可以把 unique_lockunique_ptr 类比起来,它们也支持移动语义,可以将锁的所有权转移。

初始化保护

当数据初始化的成本很高(如数据库连接)时,使用互斥量会导致不必要的性能开销。可以使用 std::call_oncestd::once_flag 来安全高效地实现一次性初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <mutex>
#include <memory>

class ExpensiveResource {
public:
void init() { /* 初始化数据库连接等操作 */ }
void use() { /* 使用资源 */ }
};

std::shared_ptr<ExpensiveResource> resource_ptr;
std::once_flag resource_flag; // 用于控制初始化的一次性执行

void init_resource() {
resource_ptr = std::make_shared<ExpensiveResource>();
resource_ptr->init();
}

void thread_safe_use() {
// 无论多少线程调用,init_resource 只会执行一次
std::call_once(resource_flag, init_resource);
resource_ptr->use(); // 安全使用已初始化的资源
}

这个也非常好理解,就像是使用了一个 bool 变量来判断连接是否已经被初始化了一样,但是使用 std::call_oncestd::once_flag 是线程安全的,不会产生竞争。这个编程技巧或许不止适用于多线程编程的场景,在一些单例模式下也可以适用。

读写锁

读写锁在线程之间的同步锁介绍过,这里主要讲Cpp中相关类的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <map>
#include <string>
#include <shared_mutex>

class DNSCache {
private:
std::map<std::string, std::string> entries;
mutable std::shared_mutex entry_mutex; // 读者-写者锁
public:
// 读取操作:使用共享锁(允许多线程并发读取)
std::string find_entry(const std::string& domain) const {
std::shared_lock<std::shared_mutex> lock(entry_mutex); // 共享锁
auto it = entries.find(domain);
return (it != entries.end()) ? it->second : "";
}
// 写入操作:使用独占锁(确保写入时无其他读写)
void update_entry(const std::string& domain, const std::string& ip) {
std::unique_lock<std::shared_mutex> lock(entry_mutex); // 独占锁
entries[domain] = ip;
}
};

如上述代码所示,Cpp中的读写锁使用 shared_mutex 来实现,在读取时,可以允许像 shared_ptr 一样被多个读者(指针)指着,而 unique_lock 就像 unique_ptr 一样,只能允许一个写者(指针),否则会等待直到没有其他线程占用。

这里的 entry_mutex 为什么要声明为 mutable
因为 find_entry 方法被声明为 const,意味着该方法在逻辑上不能修改对象内的数据(用户可见的数据,这个例子中就是DNS缓存),而 mutable 标识符可以认为这个成员变量是个例外,可以在 const 方法中被修改(即这个锁在逻辑上并不是用户可以访问的)。

锁管理器

三种锁管理器,其实上面以及有提到过了,分别是 lock_guardunique_lock 以及 shared_lock。它们就像智能指针一样,对互斥量进行封装,自动管理锁的获得与释放,防止忘记解锁的情况存在。

lock_guard

  • 最简单的锁管理器,构造时加锁,析构时解锁;
  • 不支持手动控制(如中途加锁和延迟加锁);
  • 占用资源少,速度快。

unique_lock

  • 支持延迟加锁、手动加锁、多次加锁;
  • 可以转移所有权,适合跨作用域传递锁。

shared_lock

  • 也允许多次加解锁,专为读写锁设计,允许多线程并发读取;
  • 需要与 shared_mutex 配合使用;
  • 写入时仍需独占锁。

C++并发编程(2)——线程间共享数据
http://zhouhf.top/2025/04/07/编程语言/Cpp-multithreading2-data-sharing/
作者
周洪锋
发布于
2025年4月7日
许可协议