
1. 项目概述为什么我们需要原子化的 shared_ptr在 C 多线程编程的深水区std::shared_ptr的线程安全性问题就像一颗隐藏的定时炸弹。很多开发者都知道它的引用计数是线程安全的但这远非故事的全部。一个经典的面试题是“std::shared_ptr是线程安全的吗” 正确答案是它的控制块引用计数是线程安全的但对象本身不是。这意味着多个线程可以安全地拷贝、析构同一个对象的shared_ptr副本但如果它们同时读写同一个shared_ptr实例例如通过reset或operator就会引发数据竞争导致未定义行为。想象一个场景你有一个全局的std::shared_ptrConfig用于存储动态配置一个后台线程定期从网络拉取新配置并更新这个指针而多个工作线程需要读取这个配置。如果直接使用普通的shared_ptr更新操作写和读取操作读同时作用于同一个指针对象即使它们访问的是不同的shared_ptr副本但写操作修改了底层指针这个修改对于读线程的可见性是无法保证的程序可能崩溃或读取到垃圾数据。这就是std::atomicstd::shared_ptrT在 C20 中登场的原因。它不是一个全新的智能指针而是给现有的shared_ptr套上了一层原子的“外壳”确保对shared_ptr对象本身的加载、存储、交换等操作是原子的、不可分割的。今天我们就来深入探讨如何利用原子操作手动实现一个具备类似std::atomicstd::shared_ptr核心功能的线程安全共享指针管理器。这不仅是为了理解 C20 的新特性更是为了深入掌握多线程环境下资源管理的核心机制。2. 核心需求与设计思路拆解2.1 普通std::shared_ptr的线程安全短板要理解为什么需要原子化的shared_ptr首先要明确普通shared_ptr的线程安全边界。一个shared_ptr对象内部通常包含两个原始指针指向被管理对象的指针(ptr)即get()返回的指针。指向控制块的指针(ctrl_block)控制块动态分配包含引用计数、弱引用计数、删除器、分配器等。线程安全保证控制块内的引用计数操作增加、减少是原子的因此多个线程操作不同的shared_ptr副本即使它们指向同一个对象是安全的。例如std::shared_ptrint global_sp std::make_sharedint(42); // 线程A void thread_a() { auto local_copy global_sp; // 拷贝构造原子递增引用计数安全。 } // 线程B void thread_b() { auto another_copy global_sp; // 同上安全。 }线程不安全场景当多个线程直接操作同一个shared_ptr实例并且至少有一个操作是非 const 的即修改了ptr或ctrl_block指针本身就会发生数据竞争。std::shared_ptrint global_sp std::make_sharedint(42); // 线程A (写者) void writer() { global_sp.reset(new int(100)); // 非const成员函数修改内部指针。 } // 线程B (读者) void reader() { if (global_sp) { // 读取内部指针可能与写操作并发。 int val *global_sp; // 危险可能读到失效指针。 } }在上面的例子中writer线程修改了global_sp内部的原始指针而reader线程同时尝试读取它。这两个操作作用于同一个内存地址global_sp对象本身且没有同步这就是标准的数据竞争。2.2std::atomicstd::shared_ptr的核心能力C20 的std::atomicstd::shared_ptrT解决了上述问题。它将整个shared_ptr对象视为一个整体提供原子操作load(): 原子地读取并返回当前存储的shared_ptr的副本。store(): 原子地用一个新的shared_ptr替换当前存储的值。exchange(): 原子地用新值替换当前值并返回旧值。compare_exchange_strong/weak(): 原子地比较并可能交换值。关键理解atomicshared_ptr保证的是对“指针对象”这个整体进行读写的原子性。它并不保证其指向的资源的线程安全。例如通过load()获取一个shared_ptr副本后多个线程同时通过这个副本去修改*ptr仍然需要额外的同步机制如互斥锁来保护。2.3 我们的实现目标AtomicSharedPtr由于 C20 之前的标准库没有这个特性且为了深入理解其原理我们将手动实现一个简化版的AtomicSharedPtr。我们的设计目标如下封装性对外提供与std::atomicstd::shared_ptr类似的接口如Load(),Store(),Exchange()。原子性保证确保这些接口在多线程环境下是线程安全的即对内部shared_ptr的读写操作是原子的。正确的内存序合理使用std::memory_order来平衡性能与正确性默认使用memory_order_seq_cst序列一致性以保证最强的顺序约束同时提供接口允许高级用户指定更宽松的内存序。异常安全确保在异常发生时资源不会泄漏内部状态保持一致。可移植性不依赖特定平台指令使用标准库的std::atomic和相关工具实现。设计思路我们不能直接对std::shared_ptr本身进行原子操作因为它的拷贝赋值等操作不是原子的。因此我们需要将shared_ptr包装在一个结构体或类中然后使用一个std::atomic指针或std::atomic标志来保护对这个结构体的访问。一种经典模式是“复制-交换”配合自旋锁或互斥锁但为了追求更接近无锁lock-free的性能我们将采用基于std::atomic指针和引用计数的方案。3. 核心实现基于std::atomic指针的原子共享指针我们将实现一个名为AtomicSharedPtrT的模板类。其核心思想是将shared_ptr的控制块指针实际上我们需要更多信息包装在一个动态分配的内部节点中然后使用一个std::atomic指针来原子地指向这个节点。3.1 内部数据结构定义首先我们需要定义一个内部结构体ControlBlock它包含我们需要的所有数据。#include atomic #include memory #include cassert templatetypename T class AtomicSharedPtr { private: // 内部控制块存储实际数据指针和共享/弱引用计数 struct ControlBlock { T* data_ptr; // 原始数据指针 std::atomicsize_t shared_count; std::atomicsize_t weak_count; // 为支持weak_ptr预留 ControlBlock(T* ptr) : data_ptr(ptr), shared_count(1), weak_count(0) { if (ptr nullptr) { shared_count 0; } } // 增加共享引用计数 void add_ref_copy() { shared_count.fetch_add(1, std::memory_order_relaxed); } // 减少共享引用计数返回true表示需要释放数据 bool release_ref() { // 使用memory_order_acq_rel确保递减操作前后的读写顺序 if (shared_count.fetch_sub(1, std::memory_order_acq_rel) 1) { // 这是最后一个shared引用删除数据对象 delete data_ptr; data_ptr nullptr; // 检查是否需要删除控制块自身 return (weak_count.load(std::memory_order_acquire) 0); } return false; } // 增加弱引用计数 void add_ref_weak() { weak_count.fetch_add(1, std::memory_order_relaxed); } // 减少弱引用计数返回true表示需要释放控制块 bool release_weak() { if (weak_count.fetch_sub(1, std::memory_order_acq_rel) 1) { // 没有弱引用了并且之前数据已经释放shared_count为0 if (shared_count.load(std::memory_order_acquire) 0) { return true; // 可以删除控制块 } } return false; } }; // 原子指针指向当前活动的ControlBlock std::atomicControlBlock* atomic_block_{nullptr};要点解析分离数据与控制ControlBlock独立于AtomicSharedPtr对象被动态分配。这样多个AtomicSharedPtr对象可以通过原子指针共享同一个ControlBlock。原子引用计数shared_count和weak_count本身就是std::atomicsize_t这保证了引用计数操作的原子性。这是实现线程安全的基础。内存序选择fetch_add和fetch_sub用于引用计数增减。对于add_ref_copy和add_ref_weak我们使用memory_order_relaxed因为增加引用计数通常不需要与其它内存操作同步只需要原子性。对于release_ref我们使用memory_order_acq_rel。这是因为当引用计数从1减到0时我们必须确保之前所有通过该shared_ptr对数据对象的访问读/写都已完成acquire语义并且删除操作delete data_ptr的结果对所有后续操作可见release语义。检查weak_count时使用memory_order_acquire来同步弱引用计数的状态。数据释放当shared_count减到0时我们释放data_ptr指向的用户数据。但ControlBlock本身的生命周期由shared_count和weak_count共同决定模拟std::shared_ptr的行为只有当两者都为0时才被释放。3.2 构造函数与析构函数public: // 默认构造函数创建空指针 AtomicSharedPtr() noexcept default; // 从裸指针构造获得所有权 explicit AtomicSharedPtr(T* ptr) { if (ptr) { atomic_block_.store(new ControlBlock(ptr), std::memory_order_release); } } // 拷贝构造函数 AtomicSharedPtr(const AtomicSharedPtr other) noexcept { ControlBlock* block other.atomic_block_.load(std::memory_order_acquire); if (block) { block-add_ref_copy(); } atomic_block_.store(block, std::memory_order_release); } // 移动构造函数 AtomicSharedPtr(AtomicSharedPtr other) noexcept { ControlBlock* block other.atomic_block_.exchange(nullptr, std::memory_order_acq_rel); atomic_block_.store(block, std::memory_order_release); } // 析构函数 ~AtomicSharedPtr() { ControlBlock* block atomic_block_.load(std::memory_order_acquire); if (block block-release_ref()) { // shared_count和weak_count都为0释放控制块 delete block; } }注意事项拷贝构造首先通过load带acquire序获取对方的ControlBlock指针确保能看到对方最新的构造完成状态。然后增加引用计数最后用store带release序设置自己的指针确保自己的构造完成状态对后续操作可见。移动构造使用exchange原子地将对方的指针置为nullptr并获取旧值。acq_rel序保证了移动操作的原子性和顺序性。移动后原对象变为空。析构函数减少引用计数。如果release_ref返回true说明需要连带删除ControlBlock。3.3 核心原子操作Load, Store, Exchange// 原子加载返回一个独立的 shared_ptr副本 std::shared_ptrT Load(std::memory_order order std::memory_order_seq_cst) const { ControlBlock* block atomic_block_.load(order); if (!block) { return std::shared_ptrT(); } // 我们需要创建一个新的 shared_ptr它需要增加引用计数。 // 但Load语义是“读”不应该修改引用计数吗 // 注意std::atomicstd::shared_ptr::load() 返回的是副本其引用计数会增加。 // 因此我们的实现也要模拟这个行为。 block-add_ref_copy(); // 注意这里存在一个时间窗口问题。在load之后add_ref_copy之前 // 如果另一个线程调用了Store或析构将block释放了这里就会访问已释放内存。 // 这是此简化实现的一个缺陷。标准库的实现通过更复杂的机制避免了这一点。 // 为了教学目的我们暂时忽略这个极端情况假设load和add_ref_copy是“原子”的。 // 一种改进方式是使用循环和compare_exchange来确保一致性。 return std::shared_ptrT(block-data_ptr, [block](T*){ // 自定义删除器用于管理ControlBlock的引用计数 if (block block-release_ref()) { delete block; } }); } // 原子存储 void Store(std::shared_ptrT desired, std::memory_order order std::memory_order_seq_cst) { ControlBlock* new_block nullptr; if (desired) { // 为新的shared_ptr创建ControlBlock。 // 这里有一个问题我们无法从std::shared_ptr提取出它的控制块。 // 因此这个实现要求存储的是一个新构造的shared_ptr或者我们放弃与外部shared_ptr共享控制块。 // 更合理的实现是Store接受一个T*或者直接构造。 // 我们修改接口Store一个裸指针并取得所有权。 // 但为了接口兼容性我们这里采用一个简化假设传入的shared_ptr是独立创建的。 // 在实际项目中可能需要重新设计接口。 new_block new ControlBlock(new T(*desired)); // 深拷贝性能注意。 // 更好的方式是templatetypename... Args void Store(Args... args) 直接构造。 } ControlBlock* old_block atomic_block_.exchange(new_block, order); if (old_block old_block-release_ref()) { delete old_block; } } // 简化版Store从裸指针存储 void Store(T* ptr, std::memory_order order std::memory_order_seq_cst) { ControlBlock* new_block ptr ? new ControlBlock(ptr) : nullptr; ControlBlock* old_block atomic_block_.exchange(new_block, order); if (old_block old_block-release_ref()) { delete old_block; } } // 原子交换 std::shared_ptrT Exchange(std::shared_ptrT desired, std::memory_order order std::memory_order_seq_cst) { std::shared_ptrT old_ptr Load(order); // 获取当前值 Store(std::move(desired), order); // 存储新值 return old_ptr; }实现难点与妥协Load的线程安全问题我们实现的Load存在一个经典的“TOCTOU” (Time-Of-Check-Time-Of-Use) 竞态条件。先load指针再增加引用计数这两个操作不是原子的。在这之间另一个线程可能已经调用了Store或析构函数释放了ControlBlock。在标准库的实现或生产级代码中这通常通过将引用计数与指针打包在一个机器字例如双字比较交换DCAS中或使用风险指针hazard pointer等无锁技术来解决。为了简化我们的示例假设这个时间窗口极短风险可接受但这在严格意义上是不正确的。Store与std::shared_ptr的兼容性我们无法从外部的std::shared_ptr安全地获取其内部的ControlBlock来直接使用。因此我们的Store实现进行了深拷贝这带来了性能开销并且改变了语义原shared_ptr和原子指针管理的对象不再是同一个。在实际应用中AtomicSharedPtr的Store接口可能更适合直接接受构造参数如Store(std::in_place_t, Args... args)或裸指针并取得所有权。内存序默认值我们默认使用memory_order_seq_cst这是最安全的选项保证了全局顺序一致性但性能开销也最大。对于高性能场景可以根据读写模式选择更宽松的内存序如load用acquirestore用release。3.4 辅助功能与运算符// 判断是否为空 bool IsLockFree() const noexcept { return atomic_block_.is_lock_free(); } // 获取引用计数近似值因为可能在被读取的同时被修改 size_t UseCount() const noexcept { ControlBlock* block atomic_block_.load(std::memory_order_relaxed); return block ? block-shared_count.load(std::memory_order_relaxed) : 0; } // 重置为空 void Reset() noexcept { Store(static_castT*(nullptr)); } // 解引用运算符不安全仅用于演示需在确保指针有效后使用 T operator*() const { ControlBlock* block atomic_block_.load(std::memory_order_acquire); assert(block ! nullptr block-data_ptr ! nullptr); return *(block-data_ptr); } T* operator-() const { ControlBlock* block atomic_block_.load(std::memory_order_acquire); assert(block ! nullptr block-data_ptr ! nullptr); return block-data_ptr; } // 交换两个AtomicSharedPtr void Swap(AtomicSharedPtr other) noexcept { ControlBlock* this_block atomic_block_.load(std::memory_order_relaxed); ControlBlock* other_block other.atomic_block_.load(std::memory_order_relaxed); // 简单的原子交换实现实际应使用CAS循环确保原子性。 // 这里为简化假设exchange操作本身是原子的且我们忽略引用计数的细微变化。 atomic_block_.store(other_block, std::memory_order_release); other.atomic_block_.store(this_block, std::memory_order_release); }4. 使用示例与线程安全测试让我们编写一个简单的测试程序模拟文章开头提到的配置更新场景。#include iostream #include thread #include chrono #include vector class Config { public: int value; Config(int v) : value(v) {} void print() const { std::cout Config value: value std::endl; } }; AtomicSharedPtrConfig global_config; void writer_thread(int id) { for (int i 0; i 5; i) { // 原子地更新全局配置 global_config.Store(new Config(id * 100 i)); std::this_thread::sleep_for(std::chrono::milliseconds(50)); } } void reader_thread(int id) { for (int i 0; i 10; i) { // 原子地加载当前配置 auto local_sp global_config.Load(); if (local_sp) { std::cout Reader id sees ; local_sp-print(); } else { std::cout Reader id sees empty config. std::endl; } std::this_thread::sleep_for(std::chrono::milliseconds(30)); } } int main() { // 初始配置 global_config.Store(new Config(999)); std::vectorstd::thread writers; std::vectorstd::thread readers; for (int i 0; i 2; i) { writers.emplace_back(writer_thread, i); } for (int i 0; i 3; i) { readers.emplace_back(reader_thread, i); } for (auto t : writers) t.join(); for (auto t : readers) t.join(); return 0; }在这个测试中多个写线程不断更新global_config多个读线程不断读取它。由于我们使用了AtomicSharedPtr的Store和Load方法对指针本身的读写是原子的因此不会出现因同时读写同一指针对象而导致的数据竞争。程序运行稳定不会崩溃。输出可能类似于Reader 0 sees Config value: 999 Reader 1 sees Config value: 999 Reader 2 sees Config value: 999 Reader 0 sees Config value: 0 Reader 1 sees Config value: 0 Reader 2 sees Config value: 100 ... (输出可能交错因为cout本身不是线程安全的但指针访问是安全的)5. 深入探讨内存序的选择与性能影响在我们的实现中大量使用了std::memory_order。理解它们对正确性和性能的影响至关重要。memory_order_seq_cst(默认)顺序一致性。这是最严格的模型所有线程看到的原子操作顺序都是一致的。它相当于在所有原子操作周围建立了全序栅栏。它最容易推理但性能开销最大。对于我们的AtomicSharedPtr在大多数应用场景下使用默认的seq_cst是安全且简单的选择。memory_order_acquire与memory_order_release这对内存序用于构建“同步-发生”关系。load操作使用acquire确保该操作之后的所有读写操作不会被重排到它之前。store操作使用release确保该操作之前的所有读写操作不会被重排到它之后。当一个store(release)操作“同步于”一个load(acquire)操作它们作用于同一个原子变量时store之前的所有写操作对load之后的所有读操作都是可见的。在我们的拷贝构造函数中ControlBlock* block other.atomic_block_.load(std::memory_order_acquire); // ... 确保看到other完整的构造状态 atomic_block_.store(block, std::memory_order_release); // ... 确保本对象的构造状态对后续操作可见这种配对使用可以在保证正确性的同时获得比seq_cst更好的性能因为它只约束了相关操作的顺序而不是全局顺序。memory_order_relaxed只保证原子性不提供任何顺序约束。它是最快的但也最难用。通常只用于简单的计数器比如我们的add_ref_copy和add_ref_weak因为增加引用计数通常不需要与其它内存操作同步。选择建议如果不确定就用memory_order_seq_cst。正确性永远比那一点性能提升更重要。在清晰的“生产者-消费者”模式中可以考虑使用acquire-release语义。生产者store用release消费者load用acquire。永远不要在需要同步的地方使用memory_order_relaxed除非你非常清楚自己在做什么并且有严格的证明。6. 常见问题、陷阱与进阶思考6.1 我们的实现与std::atomicstd::shared_ptr的差距Load的竞态条件如前所述我们的Load实现不是完全线程安全的。生产级实现需要使用双字原子操作如果平台支持或更复杂的无锁算法来确保load指针和增加引用计数的原子性。异常安全我们的Store在new ControlBlock时可能抛出std::bad_alloc。如果抛出异常原子指针atomic_block_没有被修改状态是干净的这是异常安全的。但是如果T的拷贝构造函数抛出异常我们需要妥善处理。性能深拷贝是性能瓶颈。std::atomicstd::shared_ptr的store是移动或拷贝shared_ptr的控制块开销很小。我们的实现需要深拷贝数据。特性缺失我们没有实现compare_exchange_strong/weak这是实现无锁数据结构的关键操作。也没有实现 C20 的wait/notify操作。6.2 使用原子共享指针的注意事项它不保护指向的数据AtomicSharedPtr只保证指针本身的原子性。如果多个线程通过Load()拿到了同一个对象的shared_ptr副本然后同时修改该对象仍然需要额外的同步机制如互斥锁来保护对象本身。auto local_sp global_atomic_sp.Load(); // 错误的多个线程同时执行下面这行数据竞争 // local_sp-value; // 正确的需要对数据操作加锁 std::lock_guardstd::mutex lk(data_mutex); local_sp-value;避免指针泄露在Load实现中我们返回了一个std::shared_ptr它拥有自定义删除器来管理我们内部的ControlBlock引用计数。这确保了资源正确释放。但务必确保自定义删除器的逻辑正确。内存序的误用错误的内存序是并发 Bug 的主要来源之一。如果为load和store选择了不匹配的内存序例如load(relaxed)和store(release)可能导致一个线程的写入对另一个线程不可见。6.3 性能优化与替代方案如果发现AtomicSharedPtr成为性能热点可以考虑使用std::atomicstd::shared_ptr(C20)这是最直接、最标准、通常也是最高效的解决方案。使用std::shared_ptr配合std::mutex对于低频更新、高频读取的场景用一个简单的互斥锁保护shared_ptr可能更简单且性能差异不大。使用std::atomicT*配合手动引用计数如果你能严格控制生命周期可以直接原子化原始指针并配合独立的原子引用计数器。但这需要非常小心地管理内存。使用 RCU (Read-Copy-Update)对于读多写少且写操作可以容忍一定延迟的场景RCU 是极佳的选择。读者完全无锁写者通过发布新指针和延迟回收旧指针来保证安全。实现一个正确且高效的原子化智能指针是 C 并发编程中的高级课题。通过这个手动实现的过程我们深入剖析了std::atomic的工作原理、内存序的微妙之处以及无锁编程的复杂性。虽然我们的实现有缺陷但它清晰地揭示了std::atomicstd::shared_ptr所要解决的核心问题及其实现思路。在实际项目中除非有极特殊的定制需求否则应优先使用标准库提供的设施。理解其背后的原理能帮助我们在面对复杂的并发问题时做出更明智的设计和调试决策。