C++多线程编程中的死锁问题

std::condition_variable - cppreference.com中给了一个多线程编程的例子。

#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
 
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread()
{
    // 等待直至 main() 发送数据
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{return ready;});
 
    // 等待后,我们占有锁。
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // 发送数据回 main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // 通知前完成手动解锁,以避免等待线程才被唤醒就阻塞(细节见 notify_one )
    lk.unlock();
    cv.notify_one();
}
 
int main()
{
    std::thread worker(worker_thread);
 
    data = "Example data";
    // 发送数据到 worker 线程
    {
        std::lock_guard<std::mutex> lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();
 
    // 等候 worker
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, []{return processed;});
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
    return 0;
}

首先我们先运行一下,看一下结果:

main() signals data ready for processing
Worker thread is processing data
Worker thread signals data processing completed
Back in main(), data = Example data after processing

在main函数中,有下面这样两段花括号包含的函数调用。

在这里,我们暂时称它们为作用域func1()、作用域func2()。

    // 发送数据到 worker 线程
    // 称之:作用域func1()
    {
        std::lock_guard<std::mutex> lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }


    // 等候 worker
    // 称之:作用域func2()
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, [] {return processed; });
    }

当把作用域func1()这一对花括号去掉以后,程序发生了死锁。

程序打印了“main() signals data ready for processing”以后就卡住了。

在分析死锁前,我们需要知道std::lock_guard<std::mutex> lk(m);

类 lock_guard 是互斥体包装器,为在作用域块期间占有互斥提供便利 RAII 风格机制。

创建 lock_guard 对象时,它试图接收给定互斥的所有权。控制离开创建 lock_guard 对象的作用域时,销毁 lock_guard 并释放互斥。

{}内的函数调用都在一个作用域内,当里面这个{},互斥锁被释放掉。

再看condition_variable的描述:

condition_variable 类是同步原语,能用于阻塞一个线程,或同时阻塞多个线程,直至另一线程修改共享变量(条件)并通知 condition_variable 。

有意修改变量的线程必须

  1. 获得 std::mutex (常通过 std::lock_guard )
  2. 在保有锁时进行修改
  3. 在 std::condition_variable 上执行 notify_one 或 notify_all (不需要为通知保有锁)

即使共享变量是原子的,也必须在互斥下修改它,以正确地发布修改到等待的线程。

wait函数的描述:

wait 导致当前线程阻塞直至条件变量被通知,或虚假唤醒发生,可选地循环直至满足某谓词。

1) 原子地解锁 lock ,阻塞当前执行线程,并将它添加到于 *this 上等待的线程列表。线程将在执行 notify_all() 或 notify_one() 时被解除阻塞。解阻塞时,无关乎原因, lock 再次锁定且 wait 退出。

2) 等价于

while (!pred()) {
    wait(lock);
}

此重载可用于在等待特定条件成为 true 时忽略虚假唤醒。注意进入此方法前,必须得到 lock , wait(lock) 退出后也会重获得它,即能以 lock 为对 pred() 访问的保障。

了解上面以后,可以发现,在原有的逻辑中,work_thread中持有锁,然后到cv.wait(lk, [] {return ready; });处释放锁。然后走到作用域func1()中,func1()持锁,修改ready值为true,离开作用域后,func1()自动地就释放掉互斥锁了。然后通过cv.notify_one();通知一个等待的线程,这时候cv.wait被唤醒并判断内部lambda表达式返回值,发现是true,则重新持锁,完成后续的操作。(注:由于CPU任务的调度,主线程和work_thread线程中函数执行顺序可能稍有不同。)

如果我们将作用域func1()的花括号去掉后,那么调用完之前func1()所有的函数调用后,互斥锁还一直被main函数所在的主线程持有,执行完cv.notify_one()后,cv.wait(lk, [] {return ready; })无法重新持锁,也就无法继续执行work_thread后面的打印。

那函数调用在哪里等待了呢?

停在了作用域func2()中互斥锁申请的地方,debug的时候我们可以看到:

现在再来看demo中work_thread中的这几句,先提前释放锁,防止主线程作用域func2()在notify_one后,wait函数拿不到锁。

    // 通知前完成手动解锁,以避免等待线程才被唤醒就阻塞(细节见 notify_one )
    lk.unlock();
    cv.notify_one();

现在再来看产生死锁的四个条件(引用自死锁产生的原因及四个必要条件 - 知乎):

(1) 互斥条件:一个资源每次只能被一个进程使用。

(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

(3) 不可剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。

(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

可以发现先前分析的例子,完全满足这4个条件。

怎么解决?破坏掉任意一个条件即可。

上述例子中,产生死锁的原因是互斥锁所持有的作用域太大,导致其他函数调用想持锁时拿不到。

互斥锁的持有范围内,应尽量地只包含到并发编程中的条件变量或特定条件等。(即改为原来的demo)

还有一种解决方案:从第二个条件“请求与保持条件”入手,取消保持这个操作我们将作用域func2()的代码改成如下:

    // 等候 worker
    {
        // 构造函数,第二个参数传入std::try_to_lock,尝试获得互斥的所有权而不阻塞
        std::unique_lock<std::mutex> lk(m, std::try_to_lock);
        cv.wait(lk, [] {return processed; });
    }

为了验证该问题,我们在work_thread释放锁之前加一个5秒的sleep。

void worker_thread()
{
    // 等待直至 main() 发送数据
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, [] {return ready; });

    // 等待后,我们占有锁。
    std::cout << getCurUtcTime() << "Worker thread is processing data\n";
    data += " after processing";

    // 发送数据回 main()
    processed = true;
    std::cout << getCurUtcTime() << "Worker thread signals data processing completed\n";

    // sleep 5 seconds
    std::chrono::milliseconds time(5000);
    std::this_thread::sleep_for(time);

    // 通知前完成手动解锁,以避免等待线程才被唤醒就阻塞(细节见 notify_one )
    lk.unlock();
    
    cv.notify_one();
}

另外添加一个打印时间的函数:

char* getCurUtcTime() {
   time_t now = time(0);
   return asctime(gmtime(&now));
}

运行一下,死锁解决。

Fri Apr 28 03:36:13 2023
main() signals data ready for processing
Fri Apr 28 03:36:13 2023
Worker thread is processing data
Fri Apr 28 03:36:13 2023
Worker thread signals data processing completed
Fri Apr 28 03:36:18 2023
Back in main(), data = Example data after processing

还有什么方法呢?

unique_lock有一个移动构造函数unique_lock(unique_lock&& other) noexcept;,other是要移动的unique_lock对象。移动构造函数将other的所有权转移到新创建的unique_lock对象中,并将other置为无效状态。

因此可以将作用域func2()改成如下,也解决了问题。

    {
        std::unique_lock<std::mutex> lk2(std::move(lk1));
        cv.wait(lk2, [] {return processed; });
    }

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阅后即奋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值