文章目录

C++ std::scoped_lock同时锁定多个互斥量避免死锁

发布于 2026-05-04 14:29:00 · 浏览 16 次 · 评论 0 条

C++ std::scoped_lock同时锁定多个互斥量避免死锁

在多线程编程中,当需要同时操作多个共享资源时,如果锁的顺序不一致,极易发生死锁。死锁会导致程序挂起,无法继续执行。C++17 引入了 std::scoped_lock,这是一个专门为了解决多锁死锁问题而设计的工具。它不仅能一次性锁定多个互斥量,还利用 RAII(资源获取即初始化)机制确保异常安全。

以下指南将详细介绍死锁的产生原因,以及如何通过 std::scoped_lock 彻底消除这一隐患。


1. 理解死锁的产生场景

在使用两个或多个互斥量(std::mutex)保护不同资源时,如果不同的线程以不同的顺序申请这些锁,就会形成循环等待。

假设有两个互斥量 mutex_amutex_b,以及两个线程:

  • 线程 1:先锁定 mutex_a,再锁定 mutex_b
  • 线程 2:先锁定 mutex_b,再锁定 mutex_a

当线程 1 持有 mutex_a 等待 mutex_b,而线程 2 持有 mutex_b 等待 mutex_a 时,两者永远无法继续。

为了更直观地理解这一过程,以下是死锁发生时的线程交互时序图:

sequenceDiagram participant T1 as "Thread 1" participant MA as "Mutex A" participant MB as "Mutex B" participant T2 as "Thread 2" Note over T1, T2: 死锁场景演示 T1->>MA: lock() activate MA MA-->>T1: 获取成功 T2->>MB: lock() activate MB MB-->>T2: 获取成功 T1->>MB: lock() (尝试获取) activate MB MB--xT1: 阻塞等待 (被 T2 持有) T2->>MA: lock() (尝试获取) activate MA MA--xT2: 阻塞等待 (被 T1 持有) Note over MA, MB: 循环等待形成,程序永久卡死

为了避免这种情况,传统的做法是要求所有开发者必须严格遵守“全局固定的加锁顺序”。但在大型项目中,依靠人为规范来约束极其脆弱。std::scoped_lock 提供了编译器和库层面的解决方案。


2. 使用 std::scoped_lock 的核心步骤

std::scoped_lock 是一个可变参数模板,它接受多个互斥量作为参数。它内部使用了一种死锁避免算法(类似于 std::lock),能够以原子性的方式尝试锁定所有互斥量,要么全部锁定成功,要么全部不锁。

2.1 准备头文件和互斥量

在使用前,包含 <mutex> 头文件,并定义你需要的互斥量对象。

#include <iostream>
#include <thread>
#include <mutex>
#include <string>

std::mutex mutex1;
std::mutex mutex2;

2.2 编写并发任务函数

创建一个函数,在该函数中需要同时访问 mutex1mutex2 保护的数据。

  1. 声明 std::scoped_lock 对象,并将所有需要锁定的互斥量作为参数传递给构造函数。
  2. 构造函数会立即执行锁定操作。
  3. 编写临界区代码(即操作共享资源的代码)。
void critical_task(int id) {
    // 第一步:同时锁定 mutex1 和 mutex2
    // 这里使用了 C++17 的初始化语句语法
    std::scoped_lock lock(mutex1, mutex2);

    // 第二步:执行临界区操作
    // 此时两个互斥量都已安全锁定,其他线程无法进入
    std::cout << "Thread " << id << " is running critical section.\n";

    // 模拟耗时操作
    std::this_thread::sleep_for(std::chrono::milliseconds(100));

    // 第三步:作用域结束,lock 对象析构
    // 析构函数会自动释放 mutex1 和 mutex2
}

3. 实战案例:银行转账系统

为了展示 std::scoped_lock 的实际效用,我们模拟一个简单的转账场景。账户之间转账需要同时锁定转出账户和转入账户,防止数据竞争。

3.1 定义账户类

定义一个简单的 Account 类,包含余额和一个互斥量。

class Account {
public:
    Account(std::string name, double money) : name_(name), money_(money) {}

    double get_money() const {
        return money_;
    }

    // 禁止拷贝构造和赋值,互斥量不可拷贝
    Account(const Account&) = delete;
    Account& operator=(const Account&) = delete;

    // 声明友元函数,以便访问私有成员
    friend void transfer(Account& from, Account& to, double amount);

private:
    std::string name_;
    double money_;
    std::mutex mtx_; // 每个账户有自己的锁
};

3.2 实现无死锁转账函数

编写 transfer 函数。如果不使用 std::scoped_lock,当 A 向 B 转账的同时 B 向 A 转账,就会发生死锁。

void transfer(Account& from, Account& to, double amount) {
    // 使用 std::scoped_lock 同时锁定两个账户的互斥量
    // 注意:这里不需要手动调用 lock()
    std::scoped_lock lock(from.mtx_, to.mtx_);

    // 临界区开始
    if (from.money_ >= amount) {
        from.money_ -= amount;
        to.money_ += amount;
        std::cout << "Transferred " << amount << " from " << from.name_ 
                  << " to " << to.name_ << "\n";
    } else {
        std::cout << "Transaction failed: " << from.name_ << " has insufficient funds.\n";
    }
    // 临界区结束,lock 离开作用域自动释放锁
}

3.3 运行并发测试

创建主函数,启动多个线程进行交叉转账。

int main() {
    Account alice("Alice", 1000);
    Account bob("Bob", 1000);

    // 线程 1:Alice 转 100 给 Bob
    std::thread t1([&]() {
        for (int i = 0; i < 10; ++i) {
            transfer(alice, bob, 10);
        }
    });

    // 线程 2:Bob 转 100 给 Alice
    // 注意:这里传递参数的顺序与 t1 相反
    // 如果使用手动 lock(lock1, lock2) 且不加小心,这里极易死锁
    // std::scoped_lock 会自动处理顺序问题
    std::thread t2([&]() {
        for (int i = 0; i < 10; ++i) {
            transfer(bob, alice, 10);
        }
    });

    // 等待线程完成
    t1.join();
    t2.join();

    std::cout << "Alice final balance: " << alice.get_money() << "\n";
    std::cout << "Bob final balance: " << bob.get_money() << "\n";

    return 0;
}

在上述代码中,线程 1 尝试以 (alice, bob) 的顺序加锁,线程 2 尝试以 (bob, alice) 的顺序加锁。std::scoped_lock 内部通过算法避免了这种顺序差异导致的死锁。


4. 对比与选择

为了帮助你在不同场景下做出选择,以下是几种锁机制的对比如下:

特性 手动多次调用 lock() std::lock + std::lock_guard std::scoped_lock (C++17)
死锁安全性 依赖人工顺序,不安全 安全(std::lock 保证) 安全(内部调用类似算法)
代码简洁性 低(需手动处理异常) 中(需两行代码) 高(一行代码搞定)
RAII 支持 需手动编写 try-catch 支持(配合 adopt_lock) 原生支持
适用版本 C++11 C++11 C++17 及以上
推荐程度 不推荐 兼容旧代码时推荐 强烈推荐

5. 关键注意事项

  1. 避免重复锁定:不要对同一个互斥量对象在同一个 std::scoped_lock 中传入两次,这会导致未定义行为(通常是死锁)。
  2. 作用域控制std::scoped_lock 的生命周期决定了锁的持有时间。确保锁的持有时间尽可能短,不要在锁内执行耗时操作(如 I/O 或繁重的计算)。
  3. 移动语义std::scoped_lock 是不可移动、不可拷贝的。它必须在其定义的作用域内使用。
  4. 参数类型:传入参数必须满足“基本互斥量”要求,如 std::mutexstd::recursive_mutexstd::shared_mutex 等。

6. 进阶:std::adopt_lock 选项

在某些极少数情况下,你可能已经手动调用了 lock(),但希望使用 RAII 机制来管理解锁。std::scoped_lock 同样支持 std::adopt_lock 参数。

mutex1.lock();
mutex2.lock();

// 告知 scoped_lock 锁已经被持有,只需负责在析构时解锁
std::scoped_lock lock(std::adopt_lock, mutex1, mutex2); 

但在绝大多数新代码中,直接让 std::scoped_lock 处理加锁是更优的选择。

通过以上步骤,你已经掌握了如何在 C++ 中使用 std::scoped_lock 来编写安全、简洁且无死锁风险的多线程代码。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文