现代C++锁介绍

文章目录

自从 C++11 在语言层面引入多线程模型之后, C++ 标准库提供了一套完整的工具, 用于实现线程同步, 防止多个线程同时访问共享资源时出现的数据竞争. 这些工具包括了基本的互斥锁 std::mutex 以及基于 RAII 的锁管理器, 如 std::lock_guard, 极大简化了开发者处理并发问题的复杂度.

在后续的标准更新中, C++ 又陆续引入了更多高级的同步机制:

  • C++14: 引入 std::shared_mutex, 支持多读单写的场景优化读性能.

  • C++17: 新增 std::shared_lockstd::scoped_lock, 分别用于管理读锁生命周期和防止多锁死锁问题.

为了让初学者更直观地理解这些锁的应用场景, 本文以银行账户管理系统为例, 逐步介绍现代 C++ 中不同锁的特点和使用方法.


场景描述

设计一个银行账户类, 可以执行以下操作:

  1. 存款(Deposit)
  2. 取款(Withdraw)
  3. 查询余额(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可以用在下面这些场景:

  1. 递归函数中的锁保护
  2. 在同一线程中调用多个依赖相同锁的函数
  3. 复杂逻辑流程中需要多次加锁

但是作者认为, 在实际开发中, 应该尽量避免使用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::mutexstd::lock_guard: 基础的互斥锁保护
  • std::shared_mutexstd::shared_lock: 适用于多读单写的优化场景
  • std::scoped_lock: 解决多锁管理中的死锁问题

希望本文能帮助你更好地理解 C++ 中的锁机制, 在实际开发中灵活选择合适的工具. 🚀

相关推荐
ragnwang2 小时前
C++ Eigen常见的高级用法 [学习笔记]
c++·笔记·学习
lqqjuly5 小时前
特殊的“Undefined Reference xxx“编译错误
c语言·c++
冰红茶兑滴水5 小时前
云备份项目--工具类编写
linux·c++
刘好念5 小时前
[OpenGL]使用 Compute Shader 实现矩阵点乘
c++·计算机图形学·opengl·glsl
酒鬼猿6 小时前
C++进阶(二)--面向对象--继承
java·开发语言·c++
姚先生976 小时前
LeetCode 209. 长度最小的子数组 (C++实现)
c++·算法·leetcode
小王爱吃月亮糖7 小时前
QT开发【常用控件1】-Layouts & Spacers
开发语言·前端·c++·qt·visual studio
aworkholic7 小时前
opencv sdk for java中提示无stiching模块接口的问题
java·c++·opencv·jni·opencv4android·stiching
程序员老冯头7 小时前
第十六章 C++ 字符串
开发语言·c++
Xenia2238 小时前
复习篇~第二章程序设计基础
c++·算法