导读:本期聚焦于小伙伴创作的《C++并发编程中死锁是怎么产生的又该如何避免》,敬请观看详情。两个线程各自持有一把锁并等待对方释放,程序就此卡死无法推进,这就是典型的死锁场景。在C++里使用std::mutex配合std::lock_guard时,若多个线程以不同顺序去锁定多个互斥量,极易触发此类问题。除了固定加锁顺序,还可借助std::lock一次性锁住多个互斥量,或采用std::scoped_lock自动管理。另外,避免锁嵌套、设置超时尝试锁也能降低风险。理解死锁四个必要条件有助于在设计与排查阶段提前规避,提升多线程程序稳定性。

在C++并发编程里,死锁是多线程程序最令人头疼的问题之一。当多个线程相互等待对方释放资源而无法继续执行时,系统就陷入了死锁状态。要写出健壮的并发代码,必须先弄清楚死锁的成因,再掌握工程中可用的规避手段。

C++并发编程中死锁是怎么产生的又该如何避免

死锁产生的四个必要条件

死锁并不是随机出现的,它必须满足四个学术上总结的条件,也就是互斥、占有且等待、不可抢占和循环等待。互斥指资源一次只能被一个线程占用,比如互斥量;占有且等待表示线程已经拿到部分资源,还想去拿别的资源且不释放手里的;不可抢占是说线程手中的资源不能被别的线程强行夺走;循环等待则是多个线程形成环形的等待链。

只要打破其中任意一个条件,死锁就不会发生。例如让线程一次性申请所有需要的锁,就破坏了占有且等待;给加锁规定全局顺序,就破坏了循环等待。理解这四点,能帮助我们在设计阶段主动规避问题,而不是等到程序假死后再去调试。

常见死锁场景与代码示例

最典型的写法是两个线程以相反顺序锁定两个互斥量。下面这段代码在并发执行时极有可能卡死:线程A先锁mutex1再锁mutex2,线程B先锁mutex2再锁mutex1,两者交错执行就会互相等待。

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

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

void thread_a() {
    mutex1.lock();
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    mutex2.lock();
    std::cout << "thread_a done" << std::endl;
    mutex2.unlock();
    mutex1.unlock();
}

void thread_b() {
    mutex2.lock();
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    mutex1.lock();
    std::cout << "thread_b done" << std::endl;
    mutex1.unlock();
    mutex2.unlock();
}

int main() {
    std::thread t1(thread_a);
    std::thread t2(thread_b);
    t1.join();
    t2.join();
    return 0;
}

上例中如果t1刚好锁住mutex1,t2刚好锁住mutex2,接下来彼此都要获取对方持有的锁,程序就会永久阻塞。这类问题在测试环境可能很少出现,但在高并发生产环境会被放大。

除了顺序反转,锁嵌套调用也是隐患。比如函数foo()锁了A再去调用bar(),而bar()内部又试图锁B,同时别的调用路径以不同顺序进入,也会引发类似问题。因此在代码评审时应重点检查跨函数加锁逻辑。

避免死锁的核心策略

固定加锁顺序

最简单也最易落地的办法是给所有互斥量编号,要求任何线程都必须按编号升序加锁。这样循环等待链无法形成,死锁自然消失。团队可以通过代码规范或静态检查工具来约束顺序。

不过这种方式依赖人工约定,在大型项目里维护成本不低。一旦新增互斥量或重构老代码,就容易遗漏顺序规则。所以它适合规模较小、锁层级清晰的模块。

使用std::lock同时加锁

C++标准库提供了std::lock函数,它可以原子地锁住多个互斥量,避免中间状态导致死锁。配合std::lock_guard的adopt_lock参数使用更安全。

#include <mutex>

std::mutex m1;
std::mutex m2;

void safe_func() {
    std::lock(m1, m2);
    std::lock_guard<std::mutex> g1(m1, std::adopt_lock);
    std::lock_guard<std::mutex> g2(m2, std::adopt_lock);
    // 业务逻辑
}

这种写法由标准库保证不会出现顺序相关的死锁,比手写顺序更可靠。缺点是只能用于已知所有锁的情况,动态数量的锁就不太方便。

采用std::scoped_lock

C++17引入的std::scoped_lock是对std::lock的进一步封装,语法更简洁,且支持可变数量互斥量。它会在构造时一次性锁好,析构时自动释放。

#include <mutex>

std::mutex a;
std::mutex b;

void modern_func() {
    std::scoped_lock lock(a, b);
    // 安全操作共享数据
}

从工程实践看,scoped_lock是目前最推荐的默认选择。它既消除了忘记解锁的风险,也顺带解决了多锁死锁。在新项目中应优先使用,而非老的lock_guard单独加锁。

超时机制与回退

当无法统一锁顺序时,可使用try_lock_fortry_lock_until尝试拿锁,若超时则释放已持有的锁并重试。这破坏了不可抢占或占有且等待条件。

#include <mutex>
#include <chrono>

std::timed_mutex tm1;
std::timed_mutex tm2;

bool attempt() {
    if (tm1.try_lock_for(std::chrono::milliseconds(50))) {
        if (tm2.try_lock_for(std::chrono::milliseconds(50))) {
            // 成功
            tm2.unlock();
            tm1.unlock();
            return true;
        }
        tm1.unlock();
    }
    return false;
}

超时方案会增加逻辑复杂度,且频繁重试可能带来性能损耗。它通常用于不能接受长阻塞的服务,例如网络线程或实时任务,作为兜底保护而非首选。

调试与检测手段

在Linux环境下,可以用ThreadSanitizer编译程序,它能动态捕获数据竞争和潜在死锁。此外,GDB附加到卡死进程后,用thread apply all bt查看各线程栈,往往能直接看到谁在等哪把锁。

日常开发中也可以封装一层日志锁,在加锁前后打印线程ID与锁地址,出问题时通过日志还原顺序。虽然会略微影响性能,但对排查偶发死锁非常实用。

策略适用场景主要优点主要缺点
固定加锁顺序小型模块直观易理解靠人工维护易出错
std::lock已知多锁标准库保障不支持动态数量
std::scoped_lock现代C++项目简洁安全需C++17
超时回退高可用服务避免永久阻塞逻辑复杂

综合来看,写C++并发程序时应优先用scoped_lock管理多锁,辅以清晰的锁层级设计,再配合 sanitizer 做定期检查,基本可以把死锁风险压到最低。

C++并发编程死锁锁顺序修改时间:2026-08-04 01:54:31

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。