mutex 与 lock_guard / unique_lock:共享数据的正确加锁姿势

发布时间:2026/10/6 12:34:08

mutex 与 lock_guard / unique_lock:共享数据的正确加锁姿势 上一篇讲完std::thread的生命周期接下来必须面对并发编程真正的核心问题多个线程同时碰同一块内存时会发生什么。答案不是「结果可能慢一点」而是「结果可以是任意值」。下面从数据竞争的原理讲起把std::mutex家族的接口、RAII 锁的三种选择、以及一次锁多把时的死锁避免说清楚每个结论都配上能跑的代码。不加锁的累加结果随机下面这段代码看起来很合理8 个线程各加 10 万次期望 80 万。#includethread#includevectorlonglongg_value0;// 没有锁保护的共享状态voidunsynchronized_add(){// 反例不要这么写g_value 1 实际是「读 - 改 - 写」三步// 两个线程可能读到同一个旧值各自加 1 再写回 —— 一次自增被吞掉for(inti0;i100000;i)g_value1;}voidrace_demo(){std::vectorstd::threadpool;for(inti0;i8;i)pool.emplace_back(unsynchronized_add);for(autot:pool)t.join();// g_value 通常小于 800000而且每次运行结果都不一样 —— 这就是数据竞争}这段没有main()是纯展示片段。之所以不给它配真实输出是因为它压根没有确定输出同一份二进制反复跑会得到 79 万、80 万附近的随机值偶尔还能「碰巧」等于 80 万。这种「有时候对」比稳定错误更危险。本地测十次都对压力一大就出错而且几乎无法复现。根本原因在编译器允许的假设单线程视角下没有其它执行流会偷偷改这块内存。于是g_value 1被拆成「读入寄存器 → 加 1 → 写回内存」三步中间随时可以被另一个线程插进来。这属于 C 标准定义的数据竞争data race行为是未定义的。它不只是算错理论上编译器可以做任何事。官方文档C 内存模型与数据竞争 — cppreference「数据竞争」的正式定义就在这里std::mutex三个接口std::mutex提供的最基本契约只有三个动作接口行为失败时lock()阻塞直到拿到锁已持锁者再调即为 UB不返回失败失败一般表现为死锁try_lock()试一下拿不到立刻返回false不阻塞返回falseunlock()释放锁未持锁时调用是 UB—用起来是这样线程 A 线程 B mutex 状态 │ │ ┌──────────┐ │ lock() ──────────────────────────────────────────────► │ 被 A 持有 │ │ 临界区: 读 value_ / 改 / 写回 │ └──────────┘ │ │ lock() ─────────────────► 阻塞等待中 │ unlock() ────────────────────────────────────────────► ┌──────────┐ │ │ 临界区开始 │ 被 B 持有 │ │ │ unlock() ──────────────► └──────────┘ 空闲手写lock()/unlock()基本必错。看反例#includemutex#includestdexceptstd::mutex g_mtx;boolg_should_failfalse;voidmanual_lock_unlock(){g_mtx.lock();// 反例不要这么写违反 Core Guidelines CP.20 / CP.50// 中间任何一步抛异常unlock() 就被跳过 —— 这把锁永久泄漏// 其它线程全部永远阻塞在 lock() 上程序表现为「莫名卡死」if(g_should_fail)throwstd::runtime_error{boom};g_mtx.unlock();}问题不在「忘了写unlock」而在**unlock和lock之间隔着一个可能抛异常的调用**。你没法保证每条路径都走到unlock除非让析构函数来兜底。这就是 RAII把锁绑在对象生命周期上析构即解锁。官方文档std::mutex — cppreference、C Core Guidelines · CP.20用 RAII不要裸 lock/unlocklock_guard最省事的 RAII 锁std::lock_guard是最简单的一档构造即加锁析构即解锁中间不给你任何操作空间。// counter_guard.cpp — 编译: g -stdc17 -Wall -O2 -pthread counter_guard.cpp -o counter_guard#includecstdio#includemutex#includethread#includevectorclassCounter{longlongvalue_{0};mutablestd::mutex mtx_;// mutablevalue() 是 const 函数也要能加锁public:voidadd(longlongdelta){conststd::lock_guardstd::mutexlock{mtx_};// 构造即锁析构即解锁value_delta;}longlongvalue()const{conststd::lock_guardstd::mutexlock{mtx_};returnvalue_;}};intmain(){constexprintkThreads8;constexprlonglongkPerThread100000;Counter counter;std::vectorstd::threadpool;pool.reserve(kThreads);for(inti0;ikThreads;i){pool.emplace_back([counter]{for(longlongk0;kkPerThread;k)counter.add(1);});}for(autot:pool)t.join();constlonglongexpectedkThreads*kPerThread;constlonglongactualcounter.value();std::printf(累加结果 %lld\n,actual);std::printf(期望值 %lld\n,expected);std::printf(是否一致 %s\n,actualexpected?是:否);}累加结果 800000 期望值 800000 是否一致 是和开头那段被吞掉自增的代码相比唯一的区别就是那句lock_guard。结果永远确定这正是加锁要买的东西把随机的错误换成确定的正确。三个实践细节lock_guard对象要const。它本身不是用来操作的加const表明「我只负责生命周期」也防止误用。作用域要尽量小。{ }括住的区间就是临界区锁持有越久其它线程等得越久。上面value()里「锁 → 读 → 返回」是最小写法绝不要在持锁期间做printf、文件 IO 或分配大内存。mutable std::mutex是必需的value()是const成员函数而lock()会修改互斥量状态所以互斥量本身必须mutable。这是「逻辑常量性」的正当用法。官方文档std::lock_guard — cppreferencelock_guard的局限也很明确不能手动unlock、不能延迟加锁、不能转移所有权、不能配条件变量。需要这些就得换下一档。unique_lock更贵但什么都能做std::unique_lock是「全功能版」。它内部多存了一个「当前是否持有锁」的标志因此比lock_guard略大、略慢一点点。很多人把它理解成「能手动解锁的lock_guard」其实多出来的那点开销正是这个标志带来的换来的是四件lock_guard做不到的事能力写法延迟加锁只包装先不锁std::unique_lockstd::mutex l{m, std::defer_lock};尝试加锁失败不阻塞std::unique_lockstd::mutex l{m, std::try_to_lock};手动unlock()/lock()随时释放并重新获取转移所有权std::unique_lockstd::mutex b{std::move(a)};配条件变量这是unique_lock存在的主要理由std::condition_variable::wait要求它最有用的是std::defer_lockstd::lock这个组合它能一次原子地锁住多把互斥量从而避免经典的「A 锁 B、B 锁 A」死锁// unique_lock_demo.cpp — 编译: g -stdc17 -Wall -O2 -pthread unique_lock_demo.cpp -o unique_lock_demo#includecstdio#includemutex#includethread#includeutilityclassAccount{longlongbalance_;mutablestd::mutex mtx_;public:explicitAccount(longlonginit):balance_{init}{}longlongbalance()const{conststd::lock_guardstd::mutexlock{mtx_};returnbalance_;}friendvoidtransfer(Accountfrom,Accountto,longlongamount);};// 一次锁两把用 std::lock 的死锁避免算法而不是手写「先锁 from 再锁 to」voidtransfer(Accountfrom,Accountto,longlongamount){std::unique_lockstd::mutexlock_from{from.mtx_,std::defer_lock};// 只包装不加锁std::unique_lockstd::mutexlock_to{to.mtx_,std::defer_lock};std::lock(lock_from,lock_to);// 要么两把都拿到要么一把都不持有from.balance_-amount;to.balance_amount;}intmain(){Account a{1000};Account b{500};std::printf(转账前: a %lld, b %lld\n,a.balance(),b.balance());// 两个方向相反的并发转账若手写「先锁 A 再锁 B」这里就会死锁std::thread t1{[a,b]{transfer(a,b,100);}};std::thread t2{[a,b]{transfer(b,a,30);}};t1.join();t2.join();std::printf(转账后: a %lld, b %lld\n,a.balance(),b.balance());std::printf(总额守恒: %s\n,(a.balance()b.balance())1500?是:否);// unique_lock 可以不拥有锁也可以把所有权转移给别人std::mutex gate;std::unique_lockstd::mutexlock{gate,std::defer_lock};std::printf(defer_lock 之后 owns_lock() %s\n,lock.owns_lock()?true:false);lock.lock();std::printf(手动 lock 之后 owns_lock() %s\n,lock.owns_lock()?true:false);std::unique_lockstd::mutexhanded_over{std::move(lock)};std::printf(move 之后: 原对象 %s, 新对象 %s\n,lock.owns_lock()?true:false,handed_over.owns_lock()?true:false);}转账前: a 1000, b 500 转账后: a 930, b 570 总额守恒: 是 defer_lock 之后 owns_lock() false 手动 lock 之后 owns_lock() true move 之后: 原对象 false, 新对象 true两个关键点std::lock(l1, l2)内部用的是「全拿或全不拿」的算法标准要求它必须避免死锁所以两个方向相反的转账不会互相卡住。自己写「先锁 A 再锁 B」在单线程时永远正确一上并发就是定时炸弹。转移所有权后原对象的owns_lock()变成false解锁责任一并转移。这正是「把锁交给别人管理」的基础也是condition_variable能工作的前提wait需要在等待期间释放锁醒来后再重新获取。官方文档std::unique_lock — cppreference、std::lock多锁死锁避免、C Core Guidelines · CP.21 / CP.22 / CP.50try_lock 与 timed_mutex等不到就走人有些场景不能无限等锁比如「拿不到就跳过这一帧」的渲染循环或者「超时就直接报错」的服务端。这时候用std::timed_mutex// try_lock.cpp — 编译: g -stdc17 -Wall -O2 -pthread try_lock.cpp -o try_lock#includeatomic#includechrono#includecstdio#includemutex#includethreadintmain(){std::timed_mutex gate;// ① 没人竞争时try_lock 立刻成功它是「试一下」不是「等下去」constboolidle_okgate.try_lock();std::printf(没人竞争: try_lock() - %s\n,idle_ok?拿到:没拿到);if(idle_ok)gate.unlock();// ② 有人持锁时try_lock_for 到点就放弃绝不无限等std::atomicboolholder_ready{false};std::thread holder{[gate,holder_ready]{gate.lock();holder_ready.store(true);std::this_thread::sleep_for(std::chrono::milliseconds{500});// 故意占住 500msgate.unlock();}};while(!holder_ready.load())std::this_thread::yield();// 等持锁者就绪constboolbusy_okgate.try_lock_for(std::chrono::milliseconds{50});std::printf(有人持锁: try_lock_for(50ms) - %s\n,busy_ok?拿到:超时放弃);if(busy_ok)gate.unlock();holder.join();constboolafter_okgate.try_lock_for(std::chrono::milliseconds{50});std::printf(持锁者退出后: try_lock_for(50ms) - %s\n,after_ok?拿到:超时放弃);if(after_ok)gate.unlock();}没人竞争: try_lock() - 拿到 有人持锁: try_lock_for(50ms) - 超时放弃 持锁者退出后: try_lock_for(50ms) - 拿到注意第三行超时不是永久失败只是「这 50ms 内没拿到」。try_lock_for一定要检查返回值再决定是否unlock否则会对着没持有的锁调用unlock那是 UB。std::mutex家族该选哪个类型同线程可重复加锁支持超时什么时候用std::mutex✗✗默认选择绝大多数场景std::recursive_mutex✓✗见下文的「通常是设计问题」std::timed_mutex✗✓try_lock_for/try_lock_until需要「等不到就走人」std::recursive_timed_mutex✓✓两者并集实践中极少用std::shared_mutexC17✗✗读多写少的读写锁关于std::recursive_mutex说句务实的它允许同一个线程重复加锁内部记一个计数看起来能救活「递归函数里顺手加锁」的代码。但递归锁几乎总是设计问题的信号。它掩盖了「临界区边界没划清楚」这件事并且会让不变量invariant在递归中途处于半成品状态别的线程看不到你自己也容易写错。真正需要它的场合通常是「要兼容一个已经写好的、内部也会加锁的第三方接口」。其余情况请改成只在最外层加一次锁内部函数换成「假设调用方已持锁」的私有实现。官方文档std::timed_mutex、std::recursive_mutex、std::shared_mutexC17三种 RAII 锁怎么选维度lock_guardunique_lockscoped_lockC17加锁时机构造即锁可选defer_lock/try_to_lock/adopt_lock构造即锁手动unlock/lock✗✓✗能否转移所有权✗✓✗能否配条件变量✗✓ 必须用它✗一次锁多把需配std::lockadopt_lock✓ 配std::lock✓ 直接传多个参数内部用死锁避免算法额外开销最小可能被优化成一个标志位也没有多一个「是否持有」标志 少量分支小适用场景简单的局部临界区默认用它条件变量 / 延迟加锁 / 转移所有权一次锁 2 把以上选型只有一句话能用lock_guard就用lock_guard一次要锁多把就用scoped_lock只有需要defer_lock、转移、或者配条件变量时才上unique_lock。不要因为unique_lock功能多就无脑用它多出来的灵活性是有成本的。官方文档std::scoped_lock — cppreference、C Core Guidelines · CP.20 / CP.50线程安全的关键字统计器把前面的东西串起来做一个多线程上报事件的次数统计// stats.cpp — 编译: g -stdc17 -Wall -O2 -pthread stats.cpp -o stats#includecstdio#includemap#includemutex#includestring#includethread#includevectorclassStats{std::mapstd::string,longlongcounts_;mutablestd::mutex mtx_;public:voidrecord(conststd::stringkey){conststd::lock_guardstd::mutexlock{mtx_};counts_[key];// 临界区读改写一个可能触发扩容的容器}std::mapstd::string,longlongsnapshot()const{conststd::lock_guardstd::mutexlock{mtx_};returncounts_;// 拷一份出去锁内不做耗时操作}};intmain(){constexprintkThreads4;constexprintkPerThread10000;conststd::vectorstd::stringkeys{cpu,mem,disk};Stats stats;std::vectorstd::threadpool;pool.reserve(kThreads);for(inti0;ikThreads;i){pool.emplace_back([stats,keys]{for(intk0;kkPerThread;k){stats.record(keys[static_caststd::size_t(k)%keys.size()]);}});}for(autot:pool)t.join();longlongtotal0;for(constauto[key,n]:stats.snapshot()){// C17 结构化绑定std::printf(%s %lld\n,key.c_str(),n);totaln;}std::printf(总计 %lld期望 %d\n,total,kThreads*kPerThread);}cpu 13336 disk 13332 mem 13332 总计 40000期望 40000这段有两个值得抄走的写法snapshot()在锁内拷贝、锁外遍历。持锁时间只够一次map拷贝而不是「持锁 循环 四次printf」。临界区越短并发度越高这是加锁优化里最有效的一招。record()里的临界区就是一次counts_[key]。std::map的插入可能触发节点分配和树旋转这些都在锁保护下完成换成std::unordered_map还要额外注意 rehash 会让所有迭代器失效。所以绝不能在锁外拿着迭代器指向 map 内部。延伸阅读std::mutex — cppreference三大接口的正式语义注意lock对已持锁者调用是 UBstd::lock_guard / std::unique_lock / std::scoped_lock三种 RAII 锁的成员清单选型前对着看一遍std::lock多锁死锁避免算法的官方说明defer_lock组合的搭档C 内存模型 — cppreference数据竞争、happens-before、顺序一致性的权威定义理解「为什么锁能修好它」的必读项C Core Guidelines · 并发篇CPCP.20、CP.21、CP.22、CP.50 都是加锁相关的硬规则收个尾数据竞争的根源是「读-改-写」不是原子操作锁把它变成了原子。三条规矩值得背下来锁一律用 RAIIlock_guard默认、scoped_lock锁多把、unique_lock只在需要延迟、转移或条件变量时才用临界区尽可能短绝不在临界区里做 IO 或长耗时操作。真正的功夫在划边界不在挑锁型。锁型选错顶多慢一点边界划错就是死锁和数据竞争后者比前者难查得多。
延伸阅读

更多相关文章

2026/10/6 12:34:08

shared_ptr 的线程安全边界:计数安全不等于对象安全

std::shared_ptr 大概是 C 里被误解最多的类型。很多人听说「它的引用计数是线程安全的」,就放心地把它当全局变量、在线程之间随手赋值,然后在大压力下偶发崩溃或者计数错乱。 真实情况要分三层看:引用计数(reference count&…

2026/10/6 13:29:11

OpenShell完全指南:Windows 11开始菜单增强与经典双栏布局定制

如果你盯着一台新装的 Windows 10 或者 Windows 11 桌面,总觉得左下角的开始菜单差点意思——想直接把常用文件夹固定进去、想要经典的双栏布局、想把关机键放到顺手的位置——那你大概率会在某个深夜的搜索引擎里输入“开始菜单增强”,然后和 OpenShell…

2026/10/6 13:29:11

批量导入TradingView警报至3Commas:add-tradingview-alerts-tool实战指南

简介:一套面向量化交易与自动交易场景的TypeScript开源工具,用于将自定义警报批量写入TradingView,专为3Commas等平台的Webhook机器人集成而设计,解决手工逐条维护数十或数百个交易对警报的痛点。工具借助Puppeteer控制自带Chromi…

2026/10/6 13:29:11

R语言生存分析实战指南:从KM曲线到Cox回归

“生存分析”这个名字听起来很有距离感,实际上它在医学随访、客户流失、设备故障、保险精算里遍地都是,核心就一句话:我们要等多久,才会看到某个事件发生?R语言在这个领域的积累非常深,survival 包有三十多…

2026/10/6 13:29:11

数字人源码实战指南:选型、部署与直播调优全解析

简介:一份面向数字人开发者与小程序爱好者的数字人源码包,聚焦虚拟数字人在微信小程序端的界面实现与交互逻辑。资源共129个文件,压缩包约687KB,涵盖19个wxml页面模板、19个wxss样式文件、35个js逻辑脚本、19个json配置以及5张jpg…

2026/10/6 13:29:11

SaaS盈利难?拆解成本黑洞、留存率与定价策略

这几年跟太多SaaS公司的创始人、运营负责人聊盈利这件事,几乎每次对话都会以同一个问题收场:账上还有钱,增长看着也还行,怎么就是不赚钱?说实话,这是SaaS赛道表面繁荣之下最普遍的焦虑。市场规模确实够大&a…

2026/10/6 13:24:11

ORA-01012错误排查全攻略:从Navicat到Oracle服务端链路解析

我最早遇到这个报错,是在一次版本发布前的数据订正窗口。Navicat连测试库连了一整天都好好的,突然某一次点击连接,直接弹了"ORA-01012: not logged on",当时第一反应是数据库是不是被谁关掉了。结果登到服务器上看监听、…

2026/10/5 6:32:56

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/6 4:01:51

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/5 17:38:27

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/6 0:03:23

MR25H40CDF+STM32F031C6工业级高可靠数据存储方案

1. 项目概述:为什么在工业现场非得用 MR25H40CDF 配 STM32F031C6 做数据存储?在工厂产线的 PLC 控制柜里、在风电变流器的散热片背面、在矿井监测终端的金属外壳下,你经常能看到一块指甲盖大小的黑色芯片——它既不是 Flash,也不是…

2026/10/6 0:03:23

MRAM+STM32工业断电数据保全实战指南

1. 项目概述:为什么在工业现场非得用 MR25H40CDF 配 STM32F031C6 做数据存储?在工厂产线的PLC柜里、在野外无人值守的环境监测终端里、在高速运转的包装机控制板上,你经常能看到一块指甲盖大小的黑色芯片,旁边贴着“MR25H40CDF”丝…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑