C++ std::lock 避免死锁

std::lock 是一个 C++ 并发工具,用于一次性锁住多个互斥锁(std::mutex),它通过确保锁定顺序一致,避免了死锁的发生。死锁是指程序中有多个共享资源,通常情况下,多个共享资源需要多个互斥锁来保护,以确保线程在访问这些资源时不会发生冲突。如果两个或更多线程在相互等待对方释放锁,就会导致它们无法继续执行,最终造成程序停滞或卡死。

例如,假设我们有两个共享资源 A 和 B,分别对应两个互斥锁 MA 和 MB。现在有两个线程 Thread1 和 Thread2,它们分别尝试访问资源 A 和 B。假设:

  • Thread1 首先锁定 MA(锁定资源 A),然后尝试锁定 MB(锁定资源 B)。
  • Thread2 首先锁定 MB(锁定资源 B),然后尝试锁定 MA(锁定资源 A)。

如果 Thread1 和 Thread2 同时执行,Thread1 会锁定 MA 并等待 MB,而 Thread2 会锁定 MB 并等待 MA。这时,两个线程互相等待对方释放锁,形成死锁,导致程序停滞。

1. 死锁场景

我们构造一个 银行账户转账操作 的多线程案例。在案例中,存在两个账户 A 和 B,我们需要做的是:

  • 开启一个线程,来完成 A 账户向 B 账户的转账 200 元的操作
  • 开启一个线程,来完成 B 账户向 A 账户的转账 100 元的操作

这个案例需要注意的是:A 向 B 转账时,我们需要同时锁定 A 账户和 B 账户,避免同时其他的账户向这两个账户转账。

我们目前的设计思路有两个:

  1. 创建一个全局互斥锁。当 A 和 B 转账时,先获得这把锁,再进行转账操作。这种方法虽然能确保数据的安全性,但缺点是限制了并发性。如果 C 和 D 账户也在进行转账操作,它们也必须等待获得锁,从而影响整体的并发性能。
  2. 给每一个账户创建一个互斥锁。当 A 和 B 转账时,我们直接获得 A 和 B 的锁,再进行转账。对于 C 和 D 的转账操作不会受到影响,有利于提高系统的并发性。

通过分析,我们认为第二种实现思路更好。下面的是实现代码:

#if 1

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>


struct BankAccount
{
    BankAccount(size_t account_id, std::string name, double balance)
        : account_id(account_id), name(name), balance(balance) {
    }

    void printBalance()
    {
        std::cout << name << "账户余额为:" << balance << std::endl;
    }

    size_t account_id;
    std::string name;
    double balance;
    std::mutex mtx;
};


void bank_transfer(BankAccount& from, BankAccount& to, double amount)
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
    from.mtx.lock();
    to.mtx.lock();

    if (from.balance >= amount)
    {
        from.balance -= amount;
        to.balance += amount;
        std::cout << "从" << from.name << "转账到" << to.name << "共计" << amount << "元!" << std::endl;
    }
    else
    {
        std::cout << from.name << "账户余额不足!" << std::endl;
    }

    to.mtx.unlock();
    from.mtx.unlock();
}

void test()
{
    BankAccount accountA(1001, "张三", 1000);
    BankAccount accountB(1002, "李四", 5000);

    std::thread t1(bank_transfer, std::ref(accountA), std::ref(accountB), 200);
    std::thread t2(bank_transfer, std::ref(accountB), std::ref(accountA), 100);

    t1.join();
    t2.join();

    accountA.printBalance();
    accountB.printBalance();
}

int main()
{
    test();
    return 0;
}


#endif

通过运行上面的代码,我们发现程序出现了 “卡死” 的现象,这就是由于死锁导致。我们分析下代码的执行逻辑,在 bank_transfer 函数中,两个线程(A 和 B)会尝试同时锁住两个账户的互斥量(mtx)。具体情况是:

  1. 线程 A 获取了 accountA 的锁,准备获取 accountB 的锁。
  2. 线程 B 获取了 accountB 的锁,准备获取 accountA 的锁。

由于两者同时等待对方的锁,这就导致了 死锁。至此,我们知道上述代码发生了什么问题。

2. 解决思路

明确了问题,怎么破解这个相互等待的问题?我们有两个解决思路:

第一个思路,我们发现 A 转账 B 和 B 转账 A 时,加锁的顺序是不一样的,如果改成一样的:

  1. 线程 A 获取了 accountA 的锁,准备获取 accountB 的锁。
  2. 线程 B 获取了 accountA 的锁,准备获取 accountB 的锁。

第二个思路,确保同时能够加锁 A 和 B,即:当线程 A 无法实现同时加锁 A 和 B 的时候,就全部释放锁。让其他线程有机会同时获得 A 和 B 的锁进行转账。这样也能够避免线程加锁 A 去等待 B,或者加锁 B 去等待 A 这种相互等待的情况。

2.1 解决方法一

#if 1

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>


struct BankAccount
{
    BankAccount(size_t account_id, std::string name, double balance)
        : account_id(account_id), name(name), balance(balance) {
    }

    void printBalance()
    {
        std::cout << name << "账户余额为:" << balance << std::endl;
    }

    size_t account_id;
    std::string name;
    double balance;
    std::mutex mtx;
};


void bank_transfer(BankAccount& from, BankAccount& to, double amount)
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
    
    // 按照账户ID大小顺序加锁,小ID先加锁,大ID后加锁
    // 无论从 A 还是 B 的角度,都能保证加锁、解锁的顺序一致。
    if (from.account_id < to.account_id)
    {
        to.mtx.lock();
        from.mtx.lock();
    }
    else
    {
        from.mtx.lock();
        to.mtx.lock();
    }

    if (from.balance >= amount)
    {
        from.balance -= amount;
        to.balance += amount;
        std::cout << "从" << from.name << "转账到" << to.name << "共计" << amount << "元!" << std::endl;
    }
    else
    {
        std::cout << from.name << "账户余额不足!" << std::endl;
    }

    to.mtx.unlock();
    from.mtx.unlock();
}

void test()
{
    BankAccount accountA(1001, "张三", 1000);
    BankAccount accountB(1002, "李四", 5000);

    std::thread t1(bank_transfer, std::ref(accountA), std::ref(accountB), 200);
    std::thread t2(bank_transfer, std::ref(accountB), std::ref(accountA), 100);

    t1.join();
    t2.join();

    accountA.printBalance();
    accountB.printBalance();
}

int main()
{
    test();
    return 0;
}


#endif

2.2 解决方法二

#if 1

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>


struct BankAccount
{
    BankAccount(size_t account_id, std::string name, double balance)
        : account_id(account_id), name(name), balance(balance) {
    }

    void printBalance()
    {
        std::cout << name << "账户余额为:" << balance << std::endl;
    }

    size_t account_id;
    std::string name;
    double balance;
    std::mutex mtx;
};


void bank_transfer(BankAccount& from, BankAccount& to, double amount)
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
    // 使用 std::lock 来保证多个锁同时加锁成功,如果有一个加锁失败,则释放锁
    std::lock(from.mtx, to.mtx);

    if (from.balance >= amount)
    {
        from.balance -= amount;
        to.balance += amount;
        std::cout << "从" << from.name << "转账到" << to.name << "共计" << amount << "元!" << std::endl;
    }
    else
    {
        std::cout << from.name << "账户余额不足!" << std::endl;
    }

    to.mtx.unlock();
    from.mtx.unlock();
}

void test()
{
    BankAccount accountA(1001, "张三", 1000);
    BankAccount accountB(1002, "李四", 5000);

    std::thread t1(bank_transfer, std::ref(accountA), std::ref(accountB), 200);
    std::thread t2(bank_transfer, std::ref(accountB), std::ref(accountA), 100);

    t1.join();
    t2.join();

    accountA.printBalance();
    accountB.printBalance();
}

int main()
{
    test();
    return 0;
}


#endif

3. 实现源码

template <class _Lock0, class _Lock1>
bool _Lock_attempt_small(_Lock0& _Lk0, _Lock1& _Lk1) 
{
    _Lk0.lock();
    {
        _Unlock_one_guard<_Lock0> _Guard{_Lk0};
        if (_Lk1.try_lock()) {
            _Guard._Lk_ptr = nullptr;
            return false;
        }
    }

    _STD this_thread::yield();
    return true;
}

template <class _Lock0, class _Lock1>
void _Lock_nonmember1(_Lock0& _Lk0, _Lock1& _Lk1) 
{
    while (_Lock_attempt_small(_Lk0, _Lk1) && _Lock_attempt_small(_Lk1, _Lk0)) {}
}

_EXPORT_STD template <class _Lock0, class _Lock1, class... _LockN>
void lock(_Lock0& _Lk0, _Lock1& _Lk1, _LockN&... _LkN) 
{
    _Lock_nonmember1(_Lk0, _Lk1, _LkN...);
}
  • _Lock_nonmember1 调用 _Lock_attempt_small 函数尝试同时对多个锁进行加锁
  • _Lock_attempt_small 函数中:
    • 首先,对其中的任意一个锁进行加锁
    • 然后,尝试对另外一个锁进行加锁,如果成功,则返回 false。_Lock_nonmember1 函数中的 while 循环退出。继续执行当前线程后续代码
    • 如果加锁失败,则对第一个锁进行释放锁的操作。并且主动放弃剩余的时间片,等待操作系统再次调度执行当前线程,相当于等待一段时间,再重新尝试对两个锁进行加锁操作。

未经允许不得转载:一亩三分地 » C++ std::lock 避免死锁
评论 (0)

4 + 1 =