1. 项目概述为什么需要关注std::shared_ptr的原子操作如果你写过一段时间的 C 多线程程序特别是那种需要在线程间安全传递或共享所有权的场景那你大概率已经和std::shared_ptr打过交道了。它帮我们自动管理引用计数省去了手动new/delete的烦恼是现代 C 资源管理的基石之一。但当你把它扔进多线程环境里事情就开始变得微妙起来。最常见的场景就是线程 A 正在复制一个指向某大对象的shared_ptr增加其引用计数与此同时线程 B 可能正在析构它持有的最后一个shared_ptr试图减少引用计数到零并删除对象。这个“增加”和“减少”的操作如果不同步会发生什么这就是典型的数据竞争。在 C11/14/17 的时代标准库提供了std::atomic_shared_ptr吗并没有。社区和实践中常见的做法是用一个互斥锁std::mutex把对同一个shared_ptr的读写操作保护起来。这当然能解决问题但锁的粒度、性能开销以及死锁风险一直是开发者心头的一根刺。我们内心深处都在呼唤一种更轻量、更原生的并发安全方案。C20 的到来终于正面回应了这个呼唤。它没有引入一个全新的atomic_shared_ptr类型而是选择了一种更优雅、更符合 C 哲学的方式为现有的std::shared_ptr模板特化了std::atomic操作。这意味着你现在可以像使用std::atomicint一样使用std::atomicstd::shared_ptrT。编译器会为你生成相应的、保证原子性的load,store,exchange,compare_exchange_strong/weak等成员函数。这不仅仅是语法糖它背后是标准对无锁编程和内存模型更深层次的承诺与实现。所以这篇内容不是简单的 API 罗列。我想和你深入聊聊在 C20 的语境下std::atomicstd::shared_ptr到底解决了什么问题它的内部机制是怎样的性能表现如何以及在实战中我们应该怎么用它又该避开哪些坑。无论你是正在重构旧的多线程代码库还是设计新的高并发组件理解这个特性都至关重要。2. 核心机制与内存模型剖析要理解std::atomicstd::shared_ptr我们不能只停留在“它能原子地读写指针”这个层面。得深入到它的“原子性”究竟覆盖了哪些部分以及它如何与 C 强大的内存模型协同工作。2.1 原子性覆盖的范围不仅仅是指针值一个std::shared_ptrT对象内部通常包含两个关键部分以常见的实现为例指向控制块control block的指针控制块里存放着引用计数可能不止一个、弱引用计数、删除器deleter、分配器allocator以及最终指向的原始对象指针。指向托管对象managed object的指针即我们通过operator-或operator*访问的那个T*。当我们谈论std::atomicstd::shared_ptrT的原子操作时它的原子性单位是整个shared_ptr对象本身。这意味着一次原子的load或store操作必须保证其他线程看到的是一个完整的、自洽的shared_ptr状态。具体来说构造完整性线程 A 原子地store了一个新构造的shared_ptr。线程 B 原子地load到这个指针时必须看到其内部的两个指针都已被正确设置并且控制块中的引用计数已经被安全地递增通常从1开始。绝不会出现 B 线程看到了对象指针但控制块指针还未初始化或引用计数不一致的“撕裂”状态。析构安全性线程 A 原子地将一个shared_ptr替换exchange为nullptr。这个操作必须原子地完成“减少原shared_ptr引用计数”和“存储新值”这两个逻辑。如果原引用计数减到零删除对象的操作并不要求是原子操作的一部分。删除操作可以在原子操作之后进行只要保证在删除期间没有其他线程能再获得指向该对象的shared_ptr即可。注意原子操作保证的是shared_ptr本身即其内部的两个指针的读写原子性以及通过原子操作引发的引用计数增减的原子性。它不保证你通过这个指针访问其所指对象*ptr时的线程安全。后者仍然需要额外的同步机制如互斥锁来保护。2.2 与内存序Memory Order的协同std::atomic的所有操作都可以指定内存序std::memory_orderstd::atomicstd::shared_ptr也不例外。这是实现高效无锁数据结构的关键。memory_order_relaxed仅保证操作本身的原子性不提供任何线程间的同步或顺序保证。在shared_ptr的语境下使用它非常危险。例如线程 A 用relaxed顺序store了一个新指针线程 B 用relaxed顺序load到这个指针但 B 可能看不到 A 在store之前对指针所指对象进行的初始化操作。这会导致数据竞争和未定义行为。在绝大多数涉及对象发布的场景下应避免对shared_ptr使用relaxed序。memory_order_release/acquire/consume这是最常用的组合。线程 A 在构造好对象并初始化后以release语义原子地store这个shared_ptr。线程 B 以acquire语义原子地load到这个shared_ptr。这建立了“同步于”synchronizes-with关系保证 B 能看到 A 在store之前的所有内存写入即对象的完整初始化状态。consume则提供更弱但可能更高效的依赖顺序但使用需格外谨慎。memory_order_seq_cst顺序一致性默认的内存序。它提供最强的保证所有线程看到的操作顺序都是一致的。它最容易推理但性能开销也最大。对于shared_ptr这种通常用于安全发布共享所有权的场景seq_cst往往是安全且简单的起点。实操心得在项目初期或对性能不极度敏感的场景可以先统一使用std::memory_order_seq_cst。等到性能 profiling 指出这里确实是热点时再根据具体的“生产者-消费者”关系谨慎地降级为release/acquire对。永远对relaxed保持警惕。2.3 内部实现猜想与性能影响标准没有规定具体的实现方式但主流编译器如 GCC、Clang 的 libstdc 和 libc的实现思路可以推测。原子化一个shared_ptr比原子化一个整数要复杂因为它涉及两个指针的读写。一种可能的实现是使用双字Double-Word原子操作如 C11 的std::atomic对某些足够小的结构体有特化或编译器内部使用__atomic_compare_exchange等内置函数处理双指针。将shared_ptr的两个内部指针打包成一个结构体例如std::pairvoid*, void*对这个结构体进行原子操作。无论具体如何实现原子操作shared_ptr的成本肯定高于非原子操作。一次原子的load或store可能相当于一次内存屏障memory barrier加上对两个指针的原子读/写。而compare_exchangeCAS操作在循环中更新所有权时开销更大。因此它的性能介于“纯互斥锁”和“理想的无锁单指针”之间。它的核心优势在于更精细的粒度和更少的阻塞概率。锁保护的是整个临界区而原子操作允许你在单个指针的读写上实现无竞争或低竞争。3. 关键 API 详解与实战代码示例理论说再多不如代码来得直观。我们来看看std::atomicstd::shared_ptrT的核心成员函数怎么用。假设我们有一个Data类需要在线程间共享。#include atomic #include memory #include thread #include iostream struct Data { int value; Data(int v) : value(v) { std::cout Data( v ) constructed.\n; } ~Data() { std::cout Data( value ) destroyed.\n; } }; std::atomicstd::shared_ptrData g_atomic_data; void producer() { // 1. 创建数据 auto new_data std::make_sharedData(42); // ... 这里可以对 new_data-value 进行初始化操作 // 2. 原子发布到全局变量。使用 memory_order_release 保证初始化对消费者可见 g_atomic_data.store(new_data, std::memory_order_release); std::cout Producer stored data.\n; } void consumer() { std::shared_ptrData local_copy; // 3. 原子加载全局变量。使用 memory_order_acquire 与 producer 同步 while (!(local_copy g_atomic_data.load(std::memory_order_acquire))) { // 数据还未准备好短暂等待实际中可能用条件变量或忙等待策略 std::this_thread::yield(); } std::cout Consumer got data, value local_copy-value \n; // 现在可以安全地使用 local_copy-value因为 acquire 保证了看到的是初始化后的值 } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); return 0; }3.1load与store基础的发布-消费如上例所示load和store是最基本的操作。store用于发布一个新值load用于获取当前值。通过配对使用release和acquire内存序可以安全地在线程间传递对象的所有权。注意事项load()返回的是一个std::shared_ptrT的副本。这个副本的引用计数会增加原子操作内部完成。这意味着只要你持有着这个local_copy对象就不会被析构。store()的参数是一个新的shared_ptr。在原子存储的过程中原原子变量中存储的旧shared_ptr的引用计数会减少新shared_ptr的引用计数会增加。这些引用计数的修改都是原子操作的一部分。3.2exchange原子地交换值exchange操作原子地用一个新值替换当前值并返回旧值。这在实现“工作窃取”work-stealing队列或状态转移时非常有用。std::atomicstd::shared_ptrData current_work; void worker_thread() { std::shared_ptrData my_work; // 尝试窃取工作原子地将当前工作换成 nullptr while (!(my_work current_work.exchange(nullptr, std::memory_order_acq_rel))) { // 如果没有工作做点别的或者等待 std::this_thread::yield(); } // 成功窃取到工作处理 my_work process(*my_work); } void post_work(std::shared_ptrData work) { // 发布新工作并获取可能被替换掉的旧工作以便清理或拒绝 auto old_work current_work.exchange(work, std::memory_order_acq_rel); if (old_work) { // 如果之前有未完成的工作可能需要处理例如拒绝新任务或记录日志 std::cout Previous work was replaced.\n; } }这里使用了memory_order_acq_rel因为它同时具有“获取”读取旧值和“释放”存储新值的语义适合这种“读-改-写”操作。3.3compare_exchange_strong/weak无锁算法的核心这是实现复杂无锁数据结构如栈、队列的基石。CAS 操作检查原子变量的当前值是否与预期值相等如果相等则用新值替换它否则将当前值加载到预期值中。// 一个简单的无锁栈节点 templatetypename T struct Node { std::shared_ptrT data; std::shared_ptrNode next; }; templatetypename T class LockFreeStack { std::atomicstd::shared_ptrNodeT head; public: void push(const T value) { auto new_node std::make_sharedNodeT(); new_node-data std::make_sharedT(value); new_node-next head.load(std::memory_order_relaxed); // 1. 读取当前头节点 // 2. CAS 循环尝试将 head 从 old_head 原子地换成 new_node while (!head.compare_exchange_weak( new_node-next, // 预期值我们以为的当前 head new_node, // 新值我们希望设置的 head std::memory_order_release, // 成功时的内存序 std::memory_order_relaxed)) { // 失败时的内存序 // 如果失败说明 head 已经被其他线程修改new_node-next 已被更新为新的 head // 循环继续尝试 } } std::shared_ptrT pop() { std::shared_ptrNodeT old_head head.load(std::memory_order_acquired); while (old_head !head.compare_exchange_weak( old_head, // 预期值我们以为的当前 head old_head-next, // 新值head 的下一个节点 std::memory_order_acq_rel, // 成功时的内存序 std::memory_order_relaxed)) { // 失败时的内存序 // 如果失败说明 head 已变更新 old_head 继续尝试 } return old_head ? old_head-data : nullptr; } };compare_exchange_strongvscompare_exchange_weakstrong保证在预期值相等时交换一定成功除非被虚假地中断比如在某些平台上被信号中断但这种情况极少。它通常用于简单的循环或失败代价高的场景。weak允许“伪失败”spurious failure即即使预期值相等操作也可能失败而返回false。这通常能带来更好的性能尤其是在循环中。最佳实践是在循环中使用compare_exchange_weak如果循环本身就是为了应对竞争而设计的如上例的栈。如果循环外只有一个 CAS 尝试或者失败后逻辑复杂则用strong。实操心得编写无锁代码时画出状态转移图并仔细考虑每个线程可能看到的中间状态至关重要。compare_exchange的参数顺序expected, desired很容易写反记住它的语义是“如果当前值等于expected就把它换成desired”。4. 性能对比、陷阱与最佳实践了解了怎么用我们还得知道什么时候用以及怎么用得好。4.1 性能对比原子操作 vs 互斥锁我们来设计一个简单的基准测试多个线程频繁地读写一个全局的shared_ptr。// 使用互斥锁 std::shared_ptrData g_data; std::mutex g_mutex; void thread_func_lock(int id) { for (int i 0; i ITERATIONS; i) { std::lock_guardstd::mutex lock(g_mutex); auto local g_data; // 复制增加引用计数 // 模拟一些读取操作 if (local) { /* do something */ } // 每隔一段时间更新数据 if (i % 100 0) { g_data std::make_sharedData(id * 1000 i); } } } // 使用原子操作 std::atomicstd::shared_ptrData g_atomic_data; void thread_func_atomic(int id) { for (int i 0; i ITERATIONS; i) { auto local g_atomic_data.load(std::memory_order_acquire); if (local) { /* do something */ } if (i % 100 0) { g_atomic_data.store(std::make_sharedData(id * 1000 i), std::memory_order_release); } } }在我的测试环境8核 CPU下当线程数较少1-2个且竞争不激烈时锁版本和原子版本差距不大甚至锁版本可能因为其简单性而略有优势。但是随着线程数增加4个以上竞争加剧atomic版本的优势开始显现吞吐量通常能高出 30% 到数倍不等。这是因为锁会导致未抢到锁的线程陷入阻塞和上下文切换而原子操作特别是load/store在无竞争时开销极小在高竞争时虽然也可能有缓存行 bouncing 的问题但通常比锁的代价小。注意这个对比不是绝对的。如果临界区内的操作本身很重比如复制一个大对象那么锁和原子操作的开销差异就会被掩盖。原子shared_ptr最适合保护的是“指针本身”这个轻量级数据的读写。4.2 常见陷阱与避坑指南误用内存序这是最大的陷阱。如前所述在发布-消费模型中滥用memory_order_relaxed会导致未定义行为。黄金法则对于存储发布使用release或seq_cst对于加载消费使用acquire或seq_cst。acq_rel用于读-改-写操作如exchange,compare_exchange。ABA 问题在无锁算法中一个经典问题是线程 A 读取共享指针P然后被挂起线程 B 修改了P指向Q然后又将P改回了原来的值但可能对象已被销毁重建。线程 A 恢复后其 CAS 操作预期值是原来的P会成功但它以为的“旧状态”其实已经历了轮回。对于shared_ptr由于其控制块是唯一的即使指向同一地址新旧shared_ptr的控制块也可能不同如果旧对象被销毁新对象在原地址重建。幸运的是std::shared_ptr的compare_exchange比较的是shared_ptr对象本身包括其内部指针而不仅仅是原始指针地址。因此如果对象被销毁重建即使地址相同shared_ptr也不相等从而避免了 ABA 问题。这是一个非常重要的优势。循环引用与原子操作原子操作不影响shared_ptr的循环引用检测。如果你在无锁数据结构中形成了环形引用例如节点互相持有shared_ptr同样会导致内存泄漏。需要结合std::weak_ptr来打破循环。不是万能的同步原语再次强调std::atomicstd::shared_ptr只保证指针本身的原子操作。它不能替代保护所指对象内部状态的锁。如果你需要基于对象状态做条件判断例如“如果值大于10则更新”你仍然需要额外的同步机制或者将整个判断-更新逻辑用原子操作建模这通常非常复杂。4.3 最佳实践总结评估需求首先问自己是否真的需要在线程间共享所有权有时移动语义std::unique_ptr或消息传递将对象拷贝到任务队列是更简单、更安全的选择。优先使用标准模式对于简单的全局状态发布load/store配合release/acquire是安全且高效的模式。谨慎设计无锁结构无锁编程难度极高。除非性能瓶颈非常明确且有充分的测试和验证否则优先考虑基于锁的或更高级的并发数据结构如std::concurrent_queue。利用 RAII 管理生命周期即使使用原子操作也要利用shared_ptr的 RAII 特性。避免手动操作原始指针。充分测试多线程代码的测试至关重要。使用线程消毒剂ThreadSanitizer等工具来检测数据竞争。进行压力测试和长时间运行测试。5. 迁移旧代码与兼容性考量如果你的项目正在从 C17 或更早版本迁移到 C20并且有多线程使用shared_ptr的代码你可能会考虑用std::atomicstd::shared_ptr来替换原有的锁。5.1 识别可替换场景查找代码中满足以下模式的锁锁保护的临界区内主要操作就是对某个shared_ptr变量进行读取、赋值或交换。没有其他共享数据需要在该锁下保护。例如// 旧代码 std::shared_ptrConfig g_config; std::mutex config_mutex; std::shared_ptrConfig get_config() { std::lock_guardstd::mutex lock(config_mutex); return g_config; } void update_config(std::shared_ptrConfig new_cfg) { std::lock_guardstd::mutex lock(config_mutex); g_config std::move(new_cfg); }这种模式是替换为原子操作的绝佳候选。5.2 逐步替换策略不建议一次性全局替换。可以采取以下步骤类型别名先为原子类型创建一个别名方便后续修改。using AtomicConfigPtr std::atomicstd::shared_ptrConfig; AtomicConfigPtr g_config;逐个函数替换将get_config和update_config函数改为使用原子操作并更新调用方。注意内存序的选择。移除互斥锁确认所有相关操作都迁移完毕后删除旧的互斥锁变量。性能测试替换后进行基准测试验证性能提升是否符合预期并确保没有引入新的竞争条件。5.3 C17 及之前的替代方案如果你的项目暂时无法升级到 C20也有替代方案std::atomic_load,std::atomic_store等自由函数C11 就提供了这些用于shared_ptr的原子操作自由函数在memory头文件中。它们的功能与 C20 的成员函数类似但语法稍显繁琐。std::shared_ptrData global_ptr; // 存储 std::atomic_store(global_ptr, std::make_sharedData(1)); // 加载 auto local std::atomic_load(global_ptr);第三方库如 Boost 库中的boost::atomic_shared_ptr或boost::shared_ptr配合boost::atomic。继续使用互斥锁在竞争不激烈或临界区复杂的情况下锁仍然是清晰、可靠的选择。我个人在实际迁移中的体会是std::atomicstd::shared_ptr的成员函数语法更加直观和统一与标准库中其他原子类型的用法保持一致减少了心智负担。它让“这个指针需要原子访问”的意图在类型系统层面就表达了出来提高了代码的可读性和可维护性。对于新的 C20 项目在处理简单的线程间shared_ptr传递时我会优先考虑它而不是直接上互斥锁。