文章目录
-
- 场景描述
- [🐞 初始实现: 非线程安全版本](#🐞 初始实现: 非线程安全版本)
- [互斥锁: `std::mutex`](#互斥锁:
std::mutex
) - [优化读操作: `std::shared_mutex`](#优化读操作:
std::shared_mutex
) - [多个锁的管理: `std::scoped_lock`](#多个锁的管理:
std::scoped_lock
) - 其他高级锁
-
- [⏳ 带超时的锁: `std::timed_mutex`](#⏳ 带超时的锁:
std::timed_mutex
) - [支持多次锁定的锁: `std::recursive_mutex`](#支持多次锁定的锁:
std::recursive_mutex
) - [带超时和可重入的锁: `std::recursive_timed_mutex`](#带超时和可重入的锁:
std::recursive_timed_mutex
)
- [⏳ 带超时的锁: `std::timed_mutex`](#⏳ 带超时的锁:
- [📅 总结](#📅 总结)
自从 C++11 在语言层面引入多线程模型之后, C++ 标准库提供了一套完整的工具, 用于实现线程同步, 防止多个线程同时访问共享资源时出现的数据竞争. 这些工具包括了基本的互斥锁 std::mutex
以及基于 RAII 的锁管理器, 如 std::lock_guard
, 极大简化了开发者处理并发问题的复杂度.
在后续的标准更新中, C++ 又陆续引入了更多高级的同步机制:
-
C++14: 引入
std::shared_mutex
, 支持多读单写的场景优化读性能. -
C++17: 新增
std::shared_lock
和std::scoped_lock
, 分别用于管理读锁生命周期和防止多锁死锁问题.
为了让初学者更直观地理解这些锁的应用场景, 本文以银行账户管理系统为例, 逐步介绍现代 C++ 中不同锁的特点和使用方法.
场景描述
设计一个银行账户类, 可以执行以下操作:
- 存款(Deposit)
- 取款(Withdraw)
- 查询余额(Get Balance)
另外需要保证这个类是线程安全的, 即多个线程可以同时对账户进行存款和取款操作, 但是不会出现数据竞争.
🐞 初始实现: 非线程安全版本
首先实现一个最基础的版本, 这个版本没有考虑多线程的问题, 多次运行这个程序就会发现最后的输出结果不会一直是 0
, 这是因为多个线程对共享资源balance
的操作存在不确定性.
cpp
#include <iostream>
#include <thread>
class BankAccount {
public:
// 存款
void Deposit(int amount) { balance_ += amount; }
// 取款
void Withdraw(int amount) { balance_ -= amount; }
// 查询余额
int GetBalance() { return balance_; }
private:
int balance_ = 0;
};
int main() {
BankAccount account;
std::thread t1([&account] { account.Deposit(100); });
std::thread t2([&account] { account.Withdraw(100); });
t1.join();
t2.join();
std::cout << "Balance: " << account.GetBalance() << std::endl;
return 0;
}
互斥锁: std::mutex
使用mutex
保护共享资源
多个线程可能同时对账户进行存款和取款操作, 为了防止数据竞争, 需要使用 std::mutex
确保同时只有一个线程可以修改账户余额.
为此我们引入一个类成员变量std::mutex
, 并在每个操作前后加锁和解锁.
比如Deposit
函数可以改为:
cpp
void Deposit(int amount) {
mutex.lock();
balance += amount;
mutex.unlock();
}
使用std::lock_guard
简化锁的管理
当前例子中被锁保护的代码比较简单, 不会发生异常. 但是实际工作中往往会遇到被锁保护的代码中可能会发生异常的情况, 或者有return
语句, 这样会导致锁无法被释放, 从而引发死锁.
cpp
void foo(int amount) {
mutex.lock();
if (amount < 0) {
mutex.unlock();
return; // 早期返回
}
bar(); // 可能会抛出异常
mutex.unlock();
}
为了解决这个问题, 我们可以使用std::lock_guard
来保证在函数退出时mutex
一定会被解锁.
std::lock_guard
是一个 RAII 风格的类, 它在构造时会锁定mutex
, 在析构时会解锁mutex
.
cpp
void Deposit(int amount) {
std::lock_guard<std::mutex> lock(mutex_);
balance_ += amount;
}
修改后的代码如下:
cpp
#include <mutex>
class BankAccount {
public:
// 存款
void Deposit(int amount) {
std::lock_guard<std::mutex> lock(mutex_);
balance_ += amount;
}
// 取款
void Withdraw(int amount) {
std::lock_guard<std::mutex> lock(mutex_);
balance_ -= amount;
}
// 查询余额
int GetBalance() {
std::lock_guard<std::mutex> lock(mutex_);
return balance_;
}
private:
int balance_ = 0;
std::mutex mutex_;
};
优化读操作: std::shared_mutex
在上面的实现中, 查询余额是只读操作, 使用互斥锁会阻塞其他线程的读操作. 为提升性能, 可引入 std::shared_mutex
实现多读单写:
std::shared_mutex
是一个读写锁, 它支持两种操作:
- 读操作: 使用(
std::shared_lock
), 多个线程可以同时获取读锁, 但不能获取写锁. 读锁的获取不会阻塞其他读锁的获取. - 写操作: 使用(
std::lock_guard
), 只有一个线程可以获取写锁, 且此时不能有其他线程获取读锁或写锁.
改进后的代码:
cpp
void Withdraw(int amount) {
// 最多只能有一个线程获得锁
std::lock_guard<std::shared_mutex> lock(mutex_);
balance_ -= amount;
}
读取操作可以这样写:
cpp
int GetBalance() {
// 可以有多个线程同时获得锁
std::shared_lock<std::shared_mutex> lock(mutex_);
return balance_;
}
如下是修改后的代码:
cpp
#include <iostream>
#include <mutex>
#include <shared_mutex>
#include <thread>
class BankAccount {
public:
// 存款
void Deposit(int amount) {
std::unique_lock<std::shared_mutex> lock(mutex_);
balance_ += amount;
}
// 取款
void Withdraw(int amount) {
std::unique_lock<std::shared_mutex> lock(mutex_);
balance_ -= amount;
}
// 查询余额
int GetBalance() {
std::shared_lock<std::shared_mutex> lock(mutex_);
return balance_;
}
private:
int balance_ = 0;
std::shared_mutex mutex_;
};
多个锁的管理: std::scoped_lock
考虑在上述BankAccount
类中增加一个转账的操作Transfer
, 转账操作需要同时锁定两个账户的锁. 如果直接使用std::lock
来锁定两个锁, 可能会出现死锁的情况.
错误的写法如下:
cpp
void Transfer(BankAccount& to, int amount) {
mutex_.lock(); // 错误写法
to.mutex_.lock(); // 错误写法
balance_ -= amount;
to.balance_ += amount;
}
为什么会出错呢, 考虑下面的使用场景:
cpp
BankAccount a, b;
std::jthread t1([&a, &b] { a.Transfer(b, 100); });
std::jthread t2([&a, &b] { b.Transfer(a, 100); });
t1
线程会锁定a
的锁, 然后尝试锁定b
的锁t2
线程会锁定b
的锁, 然后尝试锁定a
的锁
加锁的顺序不固定会导致出现死锁的情况.
使用std::scoped_lock
避免死锁
为了避免死锁, C++17 中引入了std::scoped_lock
, 它可以同时锁定多个锁, 并且避免死锁的情况.
std::scoped_lock
是一个 RAII 风格的类, 它在构造时会锁定多个mutex
, 按照一个固定顺序去锁定, 在析构时会解锁这些mutex
.
cpp
void Transfer(BankAccount& to, int amount) {
std::scoped_lock lock(mutex_, to.mutex_); // 正确写法
balance_ -= amount;
to.balance_ += amount;
}
其他高级锁
⏳ 带超时的锁: std::timed_mutex
使用场景
在某些情况下, 我们可能需要在一段时间内尝试获取锁, 如果超时则放弃获取锁. 这种情况下可以使用std::timed_mutex
.
std::timed_mutex
支持为锁定操作设置超时时间, 可以用try_lock_for()
和try_lock_until()
来尝试获取锁.
使用std::timed_mutex
实现超时锁
cpp
if (mutex.try_lock_for(std::chrono::seconds(2))) { // 尝试获取锁, 超时时间为2秒
mutex.unlock();
} else {
std::cout << "Failed to acquire lock for withdrawal within timeout.\n";
}
支持多次锁定的锁: std::recursive_mutex
使用场景
理论上std::recursive_mutex
可以用在下面这些场景:
- 递归函数中的锁保护
- 在同一线程中调用多个依赖相同锁的函数
- 复杂逻辑流程中需要多次加锁
但是作者认为, 在实际开发中, 应该尽量避免使用std::recursive_mutex
, 因为它会增加代码的复杂性, 并且容易引入死锁的风险. 而且如果一个函数需要多次加锁, 可能意味着这个函数的设计不够合理.
cpp
#include <iostream>
#include <mutex>
#include <thread>
std::recursive_mutex rmutex;
void recursive_function(int count) {
if (count <= 0) return;
rmutex.lock();
std::cout << "Thread " << std::this_thread::get_id()
<< " acquired lock at count " << count << std::endl;
recursive_function(count - 1);
rmutex.unlock();
}
int main() {
std::jthread t1(recursive_function, 5);
std::jthread t2(recursive_function, 5);
return 0;
}
带超时和可重入的锁: std::recursive_timed_mutex
是前面二者的集合体.
📅 总结
现代 C++ 提供了丰富的并发工具, 通过不同种类的锁机制, 开发者可以轻松应对复杂的多线程场景. 本文以银行账户管理系统为例, 详细阐述了以下锁的使用场景:
std::mutex
和std::lock_guard
: 基础的互斥锁保护std::shared_mutex
和std::shared_lock
: 适用于多读单写的优化场景std::scoped_lock
: 解决多锁管理中的死锁问题
希望本文能帮助你更好地理解 C++ 中的锁机制, 在实际开发中灵活选择合适的工具. 🚀