news 2026/10/20 4:34:54

C++多线程之互斥锁与死锁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++多线程之互斥锁与死锁

前言


多个线程同时读写同一块内存,如果没有同步,就是数据竞争(data race)。C++ 标准把数据竞争明确定义为未定义行为(undefined behavior, UB)——不是「结果可能偶尔不对」,而是编译器可以假设它不存在,从而做出任何优化,包括让循环永不结束、让判断被提到循环外。很多初学者看到程序「大部分时候跑得对」就以为没问题,实际上那只是 UB 恰好没有显形。


互斥锁(mutex)是最基础的同步手段:它把一段临界区变成同一时刻只允许一个线程进入。但锁本身也会引入新问题,最典型的就是死锁(deadlock)——两个线程各自持有一把锁,又都在等对方手里的那把,于是永远停在那里。另一类常见错误是「加了锁但没锁对地方」:锁的粒度太粗导致性能塌陷,粒度太细导致本该原子的复合操作被拆开。


本文先说明互斥锁要解决的问题和标准库提供的几种锁类型,再分析死锁的形成条件与代码成因,最后给出避免死锁的几种工程手段,并配合条件变量给出完整的生产者—消费者示例。文中代码以 C++17 为基准,均为按标准语义推演的示例,本机没有编译环境,未做实际运行验证。


一、为什么需要互斥锁:数据竞争是 UB


考虑一个最普通的自增操作++counter。在源码层面它是一条语句,但在机器层面通常是「读取—加一—写回」三步。两个线程交错执行时,可能都读到旧值 0,各自加一后都写回 1,最终结果是 1 而不是 2。这被称为丢失更新(lost update)。


更严重的是,C++ 的内存模型并不保证一个线程能看到另一个线程的写入。编译器可以把变量缓存在寄存器里,CPU 可以重排访存,缓存之间也可能不一致。标准通过「happens-before」关系来定义可见性:只有建立了同步关系,一个线程的写入才保证对另一个线程可见。互斥量的lock/unlock正是建立这种关系的手段之一:前一个线程unlock之前的所有写入,对后一个成功lock的线程可见。


因此,判断要不要加锁的标准不是「这段代码看起来短不短」,而是「是否存在两个线程,至少一个是写,且没有同步关系」。只读的共享数据不需要锁,前提是它在所有线程启动前就已经构造完成、之后不再修改。


一个常被忽略的点是:std::atomic<int>的自增是原子的,但「先判断再自增」这样的复合操作不是。if (flag) { ... }与另一线程对flag的修改之间仍然需要同步。原子类型只保证单次操作的不可分割性,不自动保护它周围的控制流。


二、mutex 家族与 RAII 锁管理


标准库在<mutex>中提供了几种互斥量:




类型语义典型用途



std::mutex独占所有权,不可递归绝大多数临界区

std::recursive_mutex同一线程可重复加锁递归函数或回调重入

std::timed_mutex支持try_lock_for/try_lock_until需要超时退避

std::recursive_timed_mutex递归 + 超时少数老代码

std::shared_mutex读写锁(C++17)读多写少



直接用lock()/unlock()是危险的:一旦临界区中间抛出异常或提前return,unlock就不会执行,锁永远不释放。标准库的解法是 RAII(resource acquisition is initialization)包装器,把解锁放进析构函数。


std::lock_guard是最轻的包装:构造时加锁,析构时解锁,没有其他成员函数,不能提前释放,也不能转移所有权。std::unique_lock更灵活:可以延迟加锁(std::defer_lock)、可以接管已经锁住的互斥量(std::adopt_lock)、可以手动unlock()再lock(),还可以被移动。灵活是有代价的:unique_lock通常要保存一个「是否持有锁」的标志,比lock_guard略重,且可能产生额外分支。


选择原则很简单:能用lock_guard就用lock_guard;需要在condition_variable::wait中反复解锁/加锁,或需要中途释放锁时,才用unique_lock。


// 适用于 C++17
#include <mutex>

class Counter {
public:
void increment() {
std::lock_guard<std::mutex> lock(mutex_);
++value_;
}

int value() const {
std::lock_guard<std::mutex> lock(mutex_);
return value_;
}

private:
mutable std::mutex mutex_;
int value_ = 0;
};

这里mutex_必须声明为mutable,否则const成员函数里无法调用非 const 的lock()。另外,mutex_本身不可拷贝、不可移动,因此Counter默认就是不可拷贝的;如果需要按值传递,得自己写拷贝构造,在锁的保护下复制value_,而不是复制互斥量。


三、死锁的成因与四个必要条件


死锁是指一组线程各自持有资源并等待其他线程释放资源,形成循环等待,谁也无法推进。操作系统教材常引用四个必要条件:互斥、持有并等待、不可剥夺、循环等待。四个条件同时满足才可能死锁,因此破坏其中任意一个就能避免。


在 C++ 代码里,最典型的死锁长这样:


// 适用于 C++17(反面示例:会死锁)
#include <mutex>

struct Account {
std::mutex mtx;
int balance = 100;
};

void transfer_bad(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lk1(from.mtx);
std::lock_guard<std::mutex> lk2(to.mtx); // 另一个线程反向加锁就会死锁
from.balance -= amount;
to.balance += amount;
}

线程 A 执行transfer_bad(x, y),线程 B 同时执行transfer_bad(y, x)。A 持有x.mtx等y.mtx,B 持有y.mtx等x.mtx,循环等待形成。注意这种 bug 与数据量无关,可能运行几百万次才出现一次,极难复现。


还有几种不那么直观的死锁:



  • 自死锁:同一线程对同一个非递归std::mutex连续加锁两次。标准规定这是 UB,常见表现就是挂起。典型来源是「公有函数加锁后调用另一个也会加锁的公有函数」。

  • 回调重入:持锁时调用用户提供的回调,回调内部又去拿同一把锁。即使换成了recursive_mutex,如果回调在另一个线程等待,也仍然可能死锁。

  • 条件变量使用不当:忘记在wait前持有锁,或者用notify代替谓词检查,导致错过唤醒后永久等待。

  • 锁与 join 交叉:线程 A 持锁等待线程 B 结束,而 B 结束前需要同一把锁。


std::recursive_mutex常被当成万能药,但它只是让同一线程重入不挂起,并不能解决跨线程的循环等待;而且它掩盖了「锁的职责边界不清」这一设计问题。多数情况下,正确做法是把加锁的公有函数拆成「加锁的外壳 + 不加锁的内部实现」。


四、避免死锁:同时加锁、固定顺序、缩小临界区


C++17 提供了std::scoped_lock,它可以一次锁住多个互斥量,内部用避免死锁的算法(通常与std::lock相同)完成加锁。上面的转账函数可以改写为:


// 适用于 C++17
void transfer(Account& from, Account& to, int amount) {
if (&from == &to) {
return; // 同一账户不能自己转给自己
}
std::scoped_lock lock(from.mtx, to.mtx); // 一次性锁住,内部避免循环等待
from.balance -= amount;
to.balance += amount;
}

std::scoped_lock的模板参数由构造函数自动推导(类模板实参推导,CTAD),所以不必写std::scoped_lock<std::mutex, std::mutex>。它持有的锁在析构时按逆序释放。C++11/14 中对应的写法是std::lock(from.mtx, to.mtx)加两个std::lock_guard<std::mutex>(m, std::adopt_lock),代码更长且容易漏掉adopt_lock。


如果所在标准库版本不支持scoped_lock,或者需要更细的控制,可以给每个互斥量分配一个全局唯一的序号,所有线程按序号从小到大加锁。这是破坏「循环等待」的经典办法,代价是需要维护序号,且序号不能与业务逻辑冲突。


第三种办法是超时:用std::timed_mutex的try_lock_for,拿不到就释放已持有的锁、退避后重试。它能保证程序不永久卡死,但会引入活锁(livelock)的可能,且需要仔细处理「部分加锁成功」的回滚。


第四种办法是缩小临界区。锁内不要做 I/O、不要分配大块内存、不要调用未知的回调、不要等待另一个线程。临界区越短,持锁时间越短,发生循环等待的概率越低,整体吞吐也越好。但要注意,缩小临界区不能以破坏逻辑原子性为代价:把「读—改—写」拆成三段各自加锁,等于没加锁。


条件变量也必须与互斥量配合正确:


// 适用于 C++17
#include <condition_variable>
#include <mutex>
#include <queue>

class BoundedQueue {
public:
explicit BoundedQueue(std::size_t cap) : cap_(cap) {}

void push(int v) {
std::unique_lock<std::mutex> lk(m_);
not_full_.wait(lk, [this] { return q_.size() < cap_; });
q_.push(v);
lk.unlock();
not_empty_.notify_one();
}

int pop() {
std::unique_lock<std::mutex> lk(m_);
not_empty_.wait(lk, [this] { return !q_.empty(); });
int v = q_.front();
q_.pop();
lk.unlock();
not_full_.notify_one();
return v;
}

private:
std::mutex m_;
std::condition_variable not_full_;
std::condition_variable not_empty_;
std::queue<int> q_;
std::size_t cap_;
};

wait(lk, pred)会在持有锁的情况下求值谓词;谓词为假时原子地释放锁并阻塞,被唤醒后重新加锁再求值。这个循环同时处理了虚假唤醒(spurious wakeup)和「通知早于等待」的丢失唤醒问题。手动写while (!pred) cv.wait(lk);等价,但直接用带谓词的重载更不容易写错。notify_one放在解锁之后是常见优化,避免被唤醒的线程立刻又阻塞在锁上。


完整示例


下面是一个可直接搭起来的多线程计数程序,四个线程各自累加,主线程等待全部结束后读取结果:


// 适用于 C++17
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class Counter {
public:
void increment() {
std::lock_guard<std::mutex> lock(mutex_);
++value_;
}

int value() const {
std::lock_guard<std::mutex> lock(mutex_);
return value_;
}

private:
mutable std::mutex mutex_;
int value_ = 0;
};

int main() {
Counter counter;
std::vector<std::thread> workers;

for (int i = 0; i < 4; ++i) {
workers.emplace_back([&counter] {
for (int k = 0; k < 1000; ++k) {
counter.increment();
}
});
}

for (auto& t : workers) {
t.join(); // 必须 join 或 detach,否则析构时 terminate
}

std::cout << counter.value() << '\n';
return 0;
}

按标准语义,四个线程的 4000 次自增都被同一把互斥量保护,最终结果应当是 4000;如果把lock_guard去掉,程序就存在数据竞争,结果不可预期。


常见坑点



  1. ❌ 在const成员函数里直接mutex_.lock(),编译失败后干脆去掉const。✅ 把互斥量声明为mutable std::mutex,保持接口的 const 正确性。



  1. ❌ 持锁调用回调或未知函数,回调内部再次加锁。✅ 明确锁的保护范围,回调在锁外调用;或把「加锁的公有接口」与「不加锁的实现」分离。



  1. ❌ 两个线程以不同顺序锁两个互斥量。✅ 用std::scoped_lock一次锁住,或全局规定加锁顺序。



  1. ❌ 用std::lock_guard手动unlock,或试图把锁移交给别的对象。✅ 需要提前释放或转移时改用std::unique_lock。



  1. ❌ 条件变量写成if (!ready) cv.wait(lk);,被虚假唤醒后继续执行。✅ 用cv.wait(lk, [&]{ return ready; }),谓词在持锁状态下循环求值。



  1. ❌ 认为volatile能替代互斥量,靠它让线程看到最新值。✅volatile不是同步手段,跨线程共享数据必须用std::atomic或互斥量。



  1. ❌ 为了「提高并发」把锁的粒度拆得过细,复合操作被拆到多个临界区。✅ 保证逻辑上的原子单元在同一临界区内,再在此基础上缩小范围。



  1. ❌ 线程对象析构前没有join()或detach()。✅ 每个std::thread都必须明确生命周期,否则析构时调用std::terminate。


总结




问题正确做法关键标准



数据竞争互斥量或原子操作保护数据竞争是 UB

异常导致漏解锁RAII 锁包装器lock_guard/unique_lock

多锁循环等待一次性加锁或固定顺序std::scoped_lock(C++17)

等待条件谓词 + 条件变量wait(lock, pred)

持锁过久缩小临界区,锁外做 I/O设计与测量



互斥锁解决的是「可见性与原子性」,死锁解决的是「加锁顺序与持锁范围」,两者其实是一体两面:锁用得越少、持得越短、顺序越统一,程序就越不容易出错。写多线程代码时,先想清楚哪些数据被哪些线程访问,再决定锁保护什么,比事后到处补锁有效得多。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 4:34:49

基于日志的实时数据同步:从原理到Tapdata工程实践

1. 为什么实时数据同步这件事值得单独拎出来讲数据同步这个词&#xff0c;做后端的人都不陌生。但“实时”两个字加进去之后&#xff0c;问题的复杂度会往上跳一个量级。我最早接触数据同步是在做一个内部报表系统的时候&#xff0c;当时业务方要求“准实时”看到订单数据&…

作者头像 李华
网站建设 2026/10/20 4:34:46

开源AI短剧工具“马上短剧”全流程实操指南

“短剧”这个词&#xff0c;现在已经被 AI 彻底改造了。早两年大家觉得 AI 做视频就是图一乐&#xff0c;出一张图、生成几秒动态画面就够发朋友圈了。但从去年年中开始&#xff0c;AI 短剧已经开始往“工业化”方向走了——剧本、分镜、角色、对白、配音、剪辑&#xff0c;一条…

作者头像 李华
网站建设 2026/10/20 4:34:40

电商后台商品模块设计:品牌管理、状态修改、图片上传与校验异常处理

1. 电商后台商品模块的整体设计思路拆解做电商后台系统&#xff0c;商品模块永远是绕不开的一座大山。我前后参与过几个不同规模的电商项目&#xff0c;从早期单体架构到后来的微服务拆分&#xff0c;商品中心这块的复杂度始终排在前列。这次拿一个典型的商品管理场景来聊——品…

作者头像 李华
网站建设 2026/10/20 4:34:38

ElevenLabs品牌套件:多品牌语音资产管理实践与避坑指南

前阵子给两个同时推进的项目搭语音方案时&#xff0c;我盯着一个乱糟糟的共享配置界面&#xff0c;突然意识到手里的声音资产已经失控了。不同项目共用账号、音色参数互相覆盖、多语言版本的语气对不上、团队协作全靠口头传达——这些问题全堆在一起&#xff0c;逼着我把 Eleve…

作者头像 李华
网站建设 2026/10/20 4:34:20

AI出题时代的技术面试:用稳定框架接住随机题

2026 年的技术面试&#xff0c;已经变成了"面试官对着 AI 出题面板划拉两下"的场景。上个季度我坐在某公司的终面现场&#xff0c;面试官当着我的面输入了几组关键词&#xff1a;岗位方向、候选人简历里提到的订单系统、题目难度档位。十秒钟后&#xff0c;屏幕上弹出…

作者头像 李华
网站建设 2026/10/20 4:34:11

Shuffle Attention在YOLOv8中的集成:轻量注意力提升目标检测精度

注意力机制在目标检测里早已不算新鲜玩法&#xff0c;CBAM、ECA、SE这些名字&#xff0c;但凡调过检测模型的朋友应该都见过。我在YOLOv8上折腾过一段时间注意力模块&#xff0c;之前用得比较多的是ECA&#xff0c;因为轻量&#xff1b;也上过CBAM&#xff0c;属于“重武器”&a…

作者头像 李华