C++ 多线程之互斥量(mutex)详解

发布时间:2026/10/21 14:50:13
C++ 多线程之互斥量(mutex)详解 前言多个线程同时读写同一块内存时如果没有任何同步措施程序就落入数据竞争data race。按 C 标准数据竞争是未定义行为undefined behavior, UB后果不限于“结果可能不对”编译器可以把变量缓存进寄存器、重排指令、甚至删掉整个循环程序可能崩溃也可能看起来正常而在换一台机器后出错。互斥量mutexmutual exclusion 的缩写就是标准库提供的、用来把一段代码变成“同一时刻只有一个线程能进入”的工具。围绕 mutex 最常见的误解有三个。第一认为“我只读不写所以不用加锁”——只要另一个线程在写同一个对象读线程同样构成数据竞争。第二认为volatile能代替互斥量——volatile只约束编译器对该变量的读写不能随意合并或删除它不提供原子性也不建立线程间的同步关系。第三认为“加了锁就一定安全”——锁保护的是数据不是代码如果有的访问路径忘了加同一把锁保护就形同虚设。本文从临界区与数据竞争讲起依次介绍标准库的互斥量类型、RAII 锁包装器、条件变量与互斥量的配合最后给出一个可以直接照着改写的生产者消费者完整示例并列出实际编码中最容易踩的坑。一、临界区、数据竞争与互斥量的语义临界区critical section是指一段访问共享资源的代码这段代码在执行期间必须被串行化。互斥量提供的原语只有两个加锁lock与解锁unlock。标准规定同一时刻至多有一个线程持有某个std::mutex其余线程调用lock()时会阻塞直到持有者调用unlock()。需要强调的是互斥量本身不“保护数据”。它只是建立一个约定所有访问该数据的线程都必须在持有同一把锁的前提下访问。标准库无法检查你是否遵守了这个约定。因此工程上通常把“数据 锁”封装在同一个类里让锁成为私有成员外部只能通过成员函数访问数据从结构上避免漏加锁。互斥量的另一个作用是建立“先行发生”happens-before关系。线程 A 解锁 mutex线程 B 随后锁住同一个 mutex那么 A 在解锁之前的所有写入对 B 在加锁之后都是可见的。这条规则才是“加锁能修复数据竞争”的真正依据仅仅“代码不交错执行”并不足以保证内存可见性。二、标准库提供的互斥量类型mutex头文件提供了几种互斥量它们的接口相近但语义和开销不同。下表对比常见类型。类型头文件是否可重入主要用途与注意事项std::mutexmutex否最常用的独占锁同一线程重复加锁是 UBstd::recursive_mutexmutex是允许同一线程多次加锁需同样次数的解锁通常意味着设计可以简化std::timed_mutexmutex否提供try_lock_for、try_lock_until可设置等待上限std::recursive_timed_mutexmutex是同时具备可重入与超时能力std::shared_mutexshared_mutex否C17 起提供读多写少场景的读写锁std::shared_timed_mutexshared_mutex否C14 起提供带超时的读写锁std::shared_mutex允许两种持有方式多个线程同时以共享读方式持有或一个线程以独占写方式持有。读锁用std::shared_lock管理写锁用std::unique_lock或std::lock_guard管理。它适合读操作远多于写操作、且读临界区较长的场景如果临界区本身很短读写锁的内部记账开销可能超过它省下的等待时间这一点应当通过实际测量判断而不是凭感觉下结论。std::mutex不可拷贝也不可移动通常作为类的成员或全局对象存在。它的lock()、unlock()、try_lock()是三个基础操作try_lock()成功返回 true失败返回 false不会阻塞。三、RAII 锁包装器lock_guard、unique_lock、scoped_lock直接调用lock()和unlock()是危险的临界区中任何一条提前返回或抛出的异常都会让锁永远不被释放后续线程全部卡死。标准库的做法是用 RAIIresource acquisition is initialization把锁的生命周期绑定到作用域上。std::lock_guardstd::mutex在构造时加锁、析构时解锁不可拷贝、不可移动、不能提前释放。它是最轻量的选择适合“进入作用域就加锁、离开就解锁”的简单场景。std::unique_lockstd::mutex更灵活可以延迟加锁std::defer_lock、尝试加锁std::try_to_lock、假定已加锁std::adopt_lock可以中途unlock()再lock()也可以移动给别的对象。条件变量等待必须使用unique_lock因为wait需要在等待期间解锁、醒来后重新加锁。std::scoped_lock是 C17 新增的包装器可以一次锁住多个互斥量内部使用避免死锁的算法。多个锁的场景应当优先使用它而不是手工按固定顺序加锁。// 适用于 C17#include mutexstruct Account {double balance;std::mutex m;};bool transfer(Account from, Account to, double amount) {// 同时锁住两个账户避免两个线程以相反顺序加锁造成死锁std::scoped_lock lock(from.m, to.m);if (from.balance amount) {return false;}from.balance - amount;to.balance amount;return true;}这里把数据成员设为公有只是为了让示例聚焦在锁的组织方式上。真实代码应把余额与锁都设为私有由Account自己的成员函数完成扣款和入账避免外部代码绕过锁直接改数据。四、条件变量必须与互斥量配合互斥量解决“互斥”但不解决“等待某个条件成立”。如果消费者用轮询的方式反复加锁检查队列是否为空会白白消耗 CPU。std::condition_variable让线程在条件不满足时睡眠并在其他线程改变条件后被唤醒。条件变量的使用有三条硬性规则。第一等待前必须持有锁wait会在内部原子地释放锁并进入等待。第二必须使用带谓词的重载wait(lock, pred)或者自己在循环里检查条件因为条件变量可能存在虚假唤醒spurious wakeup。第三修改共享状态并通知时通常应持有锁至少在修改时持有通知本身可以在解锁后发出以减少被唤醒线程立刻又阻塞的概率。谓词的求值发生在持有锁的状态下因此谓词读取的共享变量受到同一把锁保护不会与写线程产生竞争。完整示例有界阻塞队列下面的示例把互斥量、两个条件变量和队列组合成一个有界阻塞队列。生产者队列满时等待消费者队列空时等待关闭后所有等待者被唤醒并退出。// 适用于 C17#include condition_variable#include cstddef#include iostream#include mutex#include queue#include thread#include vectorclass BoundedQueue {public:explicit BoundedQueue(std::size_t cap) : cap_(cap) {}void push(int v) {std::unique_lockstd::mutex lk(m_);not_full_.wait(lk, [this] { return closed_ || q_.size() cap_; });if (closed_) {return;}q_.push(v);not_empty_.notify_one();}bool pop(int out) {std::unique_lockstd::mutex lk(m_);not_empty_.wait(lk, [this] { return closed_ || !q_.empty(); });if (q_.empty()) {return false;}out q_.front();q_.pop();not_full_.notify_one();return true;}void close() {std::lock_guardstd::mutex lk(m_);closed_ true;not_empty_.notify_all();not_full_.notify_all();}private:std::size_t cap_;std::queueint q_;std::mutex m_;std::condition_variable not_empty_;std::condition_variable not_full_;bool closed_ false;};int main() {BoundedQueue q(4);std::mutex io_mutex;std::thread producer([q] {for (int i 0; i 20; i) {q.push(i);}q.close();});std::vectorstd::thread consumers;for (int t 0; t 2; t) {consumers.emplace_back([q, io_mutex, t] {int value 0;while (q.pop(value)) {std::lock_guardstd::mutex lk(io_mutex);std::cout consumer t got value \n;}});}producer.join();for (auto th : consumers) {th.join();}return 0;}按标准语义推演生产者最多领先消费者cap_个元素close()之后pop会先取走队列中剩余元素取空后返回 false两个消费者线程正常退出。std::cout被单独的io_mutex保护因为标准库的流对象不保证多线程并发写入时不交错。注意push在closed_为真时直接返回调用方无法区分“入队成功”和“队列已关闭”。生产环境应返回布尔值或抛出明确的异常这里为了示例简洁做了省略。常见坑点❌ 只在写操作上加锁读操作裸奔。✅ 任何可能与写线程并发的读都必须持有同一把锁或者改用原子类型并明确设计。❌ 手动m.lock()后在中途return忘记unlock()。✅ 使用std::lock_guard或std::unique_lock让析构负责解锁。❌ 持锁执行耗时操作例如读写文件、发网络请求、调用外部回调。✅ 在锁内只复制需要的数据解锁后再做耗时工作缩小临界区。❌ 用volatile标记共享变量以为它能保证线程安全。✅ 用std::mutex保护或用std::atomic表达原子读写volatile不是同步手段。❌ 条件变量写成cv.wait(lk);不加谓词。✅ 写成cv.wait(lk, []{ return ready; });在循环语义下检查条件抵御虚假唤醒。❌ 两个线程分别先锁 A 再锁 B、先锁 B 再锁 A。✅ 用std::scoped_lock(a, b)一次锁住多个互斥量或全局规定统一的加锁顺序。❌ 用std::recursive_mutex掩盖“不知道自己有没有持锁”的设计问题。✅ 明确锁的层次与所有权把需要重入的逻辑拆成“加锁的公有函数 不加锁的私有实现”。❌ 主线程结束时仍有std::thread对象未join()或detach()。✅ 保证所有线程在析构前被join()或用 C20 的std::jthread需要 C20。总结主题关键结论数据竞争是无定义行为不是“结果可能不准”保护对象锁保护的是“所有访问路径”漏一条就等于没保护首选写法std::lock_guard用于简单作用域std::unique_lock用于条件变量多锁C17 用std::scoped_lock避免顺序死锁条件变量必须持锁等待必须带谓词修改状态后通知读写分离std::shared_mutex适合读多写少但是否划算要测量常见误区volatile不能替代互斥量递归锁不是万能补丁互斥量的正确使用核心不在记住几个 API而在于把“共享状态”和“保护它的锁”绑定成一个不可分割的整体并让所有访问路径都经过同一道门。临界区尽量小、锁的层次尽量清晰、条件等待一律带谓词这三条能避免绝大多数多线程事故。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询