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

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

shared_ptr 的线程安全边界:计数安全不等于对象安全 std::shared_ptr大概是 C 里被误解最多的类型。很多人听说「它的引用计数是线程安全的」就放心地把它当全局变量、在线程之间随手赋值然后在大压力下偶发崩溃或者计数错乱。真实情况要分三层看引用计数reference count确实用原子操作实现但shared_ptr这个对象本身不是原子的它管理的对象更不是。下面把边界划清楚哪些操作并发安全哪些是数据竞争真要「多线程换指针」时 C17 和 C20 各有什么手段。一个「偶尔」崩的线程池先看一段很常见、但确实是错的代码// 反例不要这么写数据竞争Core Guidelines 要求共享可变状态必须同步#includememorystd::shared_ptrintg_config;// 全局共享指针voidreload(){g_configstd::make_sharedint(42);// 线程 A赋值给同一个实例}voiduse(){std::shared_ptrintlocalg_config;// 线程 B拷贝同一个实例// 一旦和上面同时发生 —— 未定义行为}问题出在g_config ...和auto local g_config撞在一起前者是非 const 成员操作要改被管理指针和引用计数后者只是读被管理指针。两者落在同一个shared_ptr对象上就是标准意义上的数据竞争data race。这里最容易混的一点原子化的是控制块里的计数shared_ptr自己那两个字段被管理指针T*和控制块指针并不原子。赋值要同时改「指针指向」和「计数」这两步之间没有任何机制保证另一个线程看到的是一致状态。所以「引用计数是原子的」这句话本身没错只是它管的事情比多数人以为的少得多。控制块control block—— 被所有 shared_ptr 副本共享 ┌───────────────────────┬───────────────────┬─────────────────┐ │ 强引用计数(原子) │ 弱引用计数(原子) │ 删除器 / 分配器 │ └───────────▲───────────┴─────────▲─────────┴─────────────────┘ │ │ ┌───────┴────────┐ ┌───────┴────────┐ │ shared_ptr 实例 │ │ shared_ptr 实例 │ │ 线程 A 私有 │ │ 线程 B 私有 │ │ T* p │ ctrl* │ │ T* p │ ctrl* │ └───────┬────────┘ └────────┬───────┘ │ │ │ │ ← 不同实例各自读改自己的两个字段安全 └──────────┬───────────┘ ▼ ┌─────────────────────────────┐ │ 被管理的对象 T │ │ 这一层 shared_ptr 完全不管 │ │ 并发读写必须自己加锁/原子化 │ └─────────────────────────────┘官方文档std::shared_ptr — cppreferenceThread safety 一节是这篇全部结论的出处· std::memory_order — cppreference划边界哪些操作安全哪些是竞争shared_ptr的规则可以精确到「同一个实例」还是「不同实例」操作并发情况结论多线程各自拷贝同一个const shared_ptr实例全是只读访问安全计数用原子操作维护不同线程持有不同的shared_ptr实例互为副本各改各的两个字段安全标准明确保证任一线程对同一实例调用非 const 成员operator/reset/swap读写混在一起数据竞争一个线程写同一实例另一个线程只是拷贝它读 非 const 写数据竞争很多人误以为这样安全通过p-/*p读写被管理的对象与shared_ptr无关要自己同步shared_ptr不提供任何保护use_count()原子读安全但拿到的只是某一瞬间的快照不能当同步手段一句话记忆「不同实例安全同一实例的非 const 操作危险被管理对象全部自理」。正确姿势每个线程持有自己的副本最省事、开销也最低的做法是让每个线程都在自己的实例上工作。线程入口按值捕获或者拷一份局部副本// shared_copy.cpp — 编译: g -stdc17 -Wall -O2 -pthread shared_copy.cpp -o sc#includeatomic#includecstdio#includememory#includethread#includevectorintmain(){constexprintkThreads4;autosharedstd::make_sharedint(7);std::vectorstd::shared_ptrintcopies(kThreads);// 每个线程写自己那一格std::vectorlongcounts(kThreads,0);std::atomicintready{0};std::atomicboolgo{false};std::vectorstd::threadworkers;for(inti0;ikThreads;i){workers.emplace_back([,i]{copies[i]shared;// 拷贝构造计数原子 1ready.fetch_add(1,std::memory_order_relaxed);while(!go.load(std::memory_order_acquire)){}// 等所有线程就位counts[i]copies[i].use_count();// 此刻 4 副本 主线程 5});}while(ready.load(std::memory_order_acquire)kThreads){}go.store(true,std::memory_order_release);for(autot:workers)t.join();std::printf(并发期间各线程观察到的 use_count: %ld %ld %ld %ld\n,counts[0],counts[1],counts[2],counts[3]);copies.clear();// 销毁全部副本std::printf(副本销毁后 use_count: %ld\n,shared.use_count());}并发期间各线程观察到的 use_count: 5 5 5 5 副本销毁后 use_count: 1四个线程在不同实例上各自拷贝计数被正确维护到 5副本销毁后回到 1没有泄漏也没有提前释放。copies[i] shared写的是不同的 vector 元素彼此没有竞争。ready/go这一对原子变量纯粹是为了让输出可复现否则各线程读到的use_count取决于调度。确实要在多线程里「换指针」怎么办有些场景没法只靠副本比如配置要热更新、缓存要整体切换。这类场景的共同点是同一个变量一个线程写、其他线程读这时有三种选择。方案起始标准说明选型建议每线程持有副本C11无锁、零额外同步成本默认首选只在「需要看到最新值」时不适用std::atomic_load(p)/std::atomic_store(p, ...)自由函数C11C20 起 deprecated对shared_ptr做原子读/写C17 环境的折中方案std::atomicstd::shared_ptrTC20支持load/store/compare_exchange_*C20 环境的首选先看 C17 里能用的自由函数版本// atomic_free.cpp — 编译: g -stdc17 -Wall -O2 -pthread atomic_free.cpp -o af#includecstdio#includememory#includethreadintmain(){std::shared_ptrintconfigstd::make_sharedint(1);std::threadwriter([config]{std::atomic_store(config,std::make_sharedint(42));// 原子发布新版本});writer.join();std::shared_ptrintsnapshotstd::atomic_load(config);// 原子读取快照std::printf(快照值 %d\n,*snapshot);std::printf(config.use_count() %ld\n,config.use_count());}快照值 42 config.use_count() 2atomic_load返回的是一份独立的副本所以主线程的config和snapshot各占一份计数得到 2。读线程拿到快照之后即便写线程紧接着替换了指针它手里那个对象也保证还活着这正是这套接口存在的意义。代价是这类自由函数在 C20 里被标记为 deprecated新代码建议直接用下面的类型。官方文档std::atomic(std::shared_ptr) 自由函数 — cppreferenceC20 把这件事做进了类型系统std::atomicstd::shared_ptrT是正式的偏特化并且提供compare_exchange_strong/compare_exchange_weak可以写无锁的读-改-写循环。下面 4 个线程各做 1000 次「读到当前值、发布一个 1 的新对象」最终必然是 4001// verify: stdc20// atomic_rmw.cpp — 需要 C20编译: g -stdc20 -Wall -O2 -pthread atomic_rmw.cpp -o armw#includeatomic#includecstdio#includememory#includethread#includevectorintmain(){constexprintkThreads4;constexprintkRounds1000;// C20 起 std::atomicstd::shared_ptrT 是标准偏特化std::atomicstd::shared_ptrintg{std::make_sharedint(1)};std::vectorstd::threadworkers;for(intt0;tkThreads;t){workers.emplace_back([g]{for(intk0;kkRounds;k){autocurg.load();for(;;){autonextstd::make_sharedint(*cur1);if(g.compare_exchange_weak(cur,next))break;// 失败时 cur 被刷新}}});}for(autot:workers)t.join();std::printf(最终值 %d\n,*g.load());std::printf(sizeof(std::atomicstd::shared_ptrint) %zu\n,sizeof(std::atomicstd::shared_ptrint));}最终值 4001 sizeof(std::atomicstd::shared_ptrint) 16有两点值得留意。一是compare_exchange_weak失败时会顺手把cur刷新成最新值所以循环体里不用重新load也不会读到过期对象这个语义和普通 CAS 一致。二是sizeof仍是 16也就是「被管理指针 控制块指针」没有为原子性多付一个字的存储gcc 的实现把自旋计数藏在了控制块里。官方文档std::atomicstd::shared_ptr — cppreference · std::thread — cppreference多线程共享只读配置把上面的建议落地也就是共享数据一旦发布就不再修改各线程各拿一份副本。这是 90% 场景的正解也是唯一不需要锁的方案// shared_config.cpp — 编译: g -stdc17 -Wall -O2 -pthread shared_config.cpp -o sconf#includecstdio#includememory#includestring#includethread#includevectorclassConfig{public:Config(std::string name,intworkers):name_(std::move(name)),workers_(workers){}conststd::stringname()const{returnname_;}intworkers()const{returnworkers_;}private:std::string name_;intworkers_;};intmain(){constexprintkThreads3;autoconfigstd::make_sharedConfig(prod,4);std::vectorstd::stringseen(kThreads);std::vectorstd::threadworkers;for(inti0;ikThreads;i){workers.emplace_back([,i]{std::shared_ptrConfiglocalconfig;// 拷贝构造线程私有实例seen[i]local-name()/std::to_string(local-workers());});}for(autot:workers)t.join();for(inti0;ikThreads;i)std::printf(线程 %d 看到 %s\n,i,seen[i].c_str());std::printf(主线程 use_count %ld\n,config.use_count());}线程 0 看到 prod/4 线程 1 看到 prod/4 线程 2 看到 prod/4 主线程 use_count 1local是每个线程自己的shared_ptrconfig只被读取、从未被写整个程序没有任何数据竞争。Config本身也是只读的成员函数全const构造后不再变所以共享它不需要任何锁。不可变数据配上引用计数是最省心的一种形态。延伸阅读std::shared_ptr — cppreference —— Thread safety 一节逐条列出「不同实例安全 / 同一实例非 const 操作不安全」的原文措辞std::atomic(std::shared_ptr) 自由函数 — cppreference ——atomic_load/atomic_store系列的全部重载与 deprecated 状态std::atomicstd::shared_ptr — cppreference —— C20 偏特化含compare_exchange_weak的语义std::memory_order — cppreference —— 为什么作者写acquire/release而不是默认的seq_cstC Core Guidelines —— 并发一章的总原则共享可变状态必须有同步能靠「不共享」解决就不要靠锁收个尾回到开头那句传言。shared_ptr保证的只有一件事控制块里的引用计数是原子的多个副本不会把计数算错。同一个实例上一旦出现非 const 操作、reset、swap同步就得你自己来被管理的对象更是从头到尾没人替你管。选型的顺序倒是很清楚先考虑让每个线程持有自己的副本最省事也最快C17 里确实要热替换用std::atomic_load/std::atomic_store这对自由函数到了 C20 直接上std::atomicstd::shared_ptrT还能写 CAS 循环。不需要热替换就别碰原子版本多出来的开销换不到任何东西。
延伸阅读

更多相关文章

2026/10/6 12:34:08

STM32 时钟电路经典搭配

STM32 外部时钟系统分为高速外部时钟HSE、低速外部时钟LSE,是工程开发、学习调试、产品量产的唯一主流方案,精度与稳定性远超内部RC时钟。本文以12MHz无源HSE晶振为核心,结合ST官方规范,整理纯实操、可直接套用的标准时钟电路搭配…

2026/10/6 13:24:11

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

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

2026/10/6 13:24:11

基于SQLite FTS5的中文全文搜索实现及踩坑指南

今天是“30天挑战”的第11天,整个项目刚好走完三分之一。先交代一下背景:我在做的是一个本地优先的 Markdown 知识管理工具 DayNotes,要求数据完全离线、启动速度快、折腾成本低。前 10 天已经完成了文档解析、编辑器、标签体系和列表页&…

2026/10/6 13:24:11

MySQL int(1) 与 int(10) 的区别:显示宽度背后的真相与版本演进

先问个问题:建表的时候看到int(10),你的第一反应是什么?我猜不少人和我一样,一开始以为它和varchar(10)类似,表示这个字段最多只能存 10 个字符,所以int(1)就只能存一位数字。这个理解错得相当离谱&#xf…

2026/10/6 13:24:10

conda环境nvcc not found?CUDA 11.6编译链配置与排查指南

去年底帮同事在Windows上配深度学习环境,遇到一个特别经典也特别容易让人懵的报错:新建好一个conda环境之后,在终端里敲 nvcc -V ,系统直接甩回来一句 nvcc 不是内部或外部命令,也不是可运行的程序,或批…

2026/10/6 13:24:10

SVDD与OCSVM深度对比:单类分类算法原理、差异与选型实践

做异常检测这几年,SVDD和OCSVM这两个名字几乎每次都一起出现。很多人问过我:这两个到底什么区别?选哪个好?为什么我换了数据集之后,结果“风水轮流转”?说实话,这两个算法确实长得像亲兄弟&…

2026/10/6 13:19:10

Docker部署Raneto:构建轻量级Markdown知识库实战

团队文档散落在聊天记录、本地文件夹和各式在线文档里,每次想找一份几个月前写过的方案都要翻半天。这种混乱让我下定决心建一套统一的知识库。研究了一圈之后,真正打动我的不是功能大而全的重型系统,而是 Raneto 这种足够轻、足够透明的方案…

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
免费获取方案
☎咨询二维码 ☎ ↑