LLVM17下clang++双向weak_ptr引发TSAN数据竞争问题咨询
我使用搭配LLVM17的clang++,现有Foo和Bar两个类,二者通过weak_ptr成员变量双向引用。多线程场景下,线程获取shared_ptr<Bar>副本后销毁时,TSAN会报告数据竞争。
示例代码
class Bar; class Foo : public std::enable_shared_from_this<Foo> { public: std::shared_ptr<Bar> get_instance() { std::lock_guard<std::mutex> lck(m_); std::shared_ptr<Bar> sh = _ptr.lock(); if (sh) { return sh; } else { sh = std::make_shared<Bar>(weak_from_this()); _ptr = sh; // <<<<< TSAN关联位置 } return sh; } private: std::weak_ptr<Bar> _ptr; std::mutex m_; }; class Bar { public: Bar(std::weak_ptr<Foo> ptr) : _ctx(ptr) {} private: std::weak_ptr<Foo> _ctx; }; void get_and_release(std::shared_ptr<Foo> aP) { for (int i=0; i<1000; i++) { std::shared_ptr<Bar> p = aP->get_instance(); // <<<<< 销毁时触发竞争 } } int main() { std::shared_ptr<Foo> aP = std::make_shared<Foo>(); std::vector<std::thread> threads; for (int t=0; t<4; t++) { threads.emplace_back(get_and_release, aP); } for (auto& thread : threads) { thread.join(); } }
TSAN报错信息
WARNING: ThreadSanitizer: data race (pid=21947) Write of size 8 at 0x7b0c00001800 by thread T2 (mutexes: write M0): #0 operator delete(void*) ../lib/tsan/rtl/tsan_new_delete.cpp:126:3 (example+0xea98e) #1 void std::__1::__libcpp_operator_delete[abi:ue170006]<void*>(void*) ../llvm-17.0.6-n6964152/include/c++/v1/new:278:3 (example+0xed245) #2 void std::__1::__do_deallocate_handle_size[abi:ue170006]<>(void*, unsigned long) ../llvm-17.0.6-n6964152/include/c++/v1/new:302:10 (example+0xed1c1) #3 std::__1::__libcpp_deallocate[abi:ue170006](void*, unsigned long, unsigned long) ../llvm-17.0.6-n6964152/include/c++/v1/new:318:14 (example+0xed0da) #4 std::__1::allocator<std::__1::__shared_ptr_emplace<Bar, std::__1::allocator<Bar> > >::deallocate[abi:ue170006](std::__1::__shared_ptr_emplace<Bar, std::__1::allocator<Bar> >*, unsigned long) ../llvm-17.0.6-n6964152/include/c++/v1/__memory/allocator.h:130:13 (example+0xed03e) #5 std::__1::allocator_traits<std::__1::allocator<std::__1::__shared_ptr_emplace<Bar, std::__1::allocator<Bar> > > >::deallocate[abi:ue170006](std::__1::allocator<std::__1::__shared_ptr_emplace<Bar, std::__1::allocator<Bar> > >&, std::__1::__shared_ptr_emplace<Bar, std::__1::allocator<Bar> >*, unsigned long) ../llvm-17.0.6-n6964152/include/c++/v1/__memory/allocator_traits.h:288:13 (example+0xecfc5) #6 std::__1::__shared_ptr_emplace<Bar, std::__1::allocator<Bar> >::__on_zero_shared_weak() ../llvm-17.0.6-n6964152/include/c++/v1/__memory/shared_ptr.h:332:9 (example+0xecc7c) #7 std::__1::weak_ptr<Bar>::~weak_ptr() ../llvm-17.0.6-n6964152/include/c++/v1/__memory/shared_ptr.h:1777:19 (example+0xed786) #8 std::__1::enable_if<__compatible_with<Bar, Bar>::value, std::__1::weak_ptr<Bar>&>::type std::__1::weak_ptr<Bar>::operator=<Bar>(std::__1::shared_ptr<Bar> const&) ../llvm-17.0.6-n6964152/include/c++/v1/__memory/shared_ptr.h:1836:5 (example+0xebf37) #9 Foo::get_instance() A.cc:18:18 (example+0xeb5c7) #10 get_and_release(std::__1::shared_ptr<Foo>) A.cc:37:38 (example+0xeb305) Previous read of size 8 at 0x7b0c00001800 by thread T1: #0 std::__1::__shared_count::__release_shared[abi:ue170006]() ../llvm-17.0.6-n6964152/include/c++/v1/__memory/shared_ptr.h:172:9 (example+0xed8f0) #1 std::__1::__shared_weak_count::__release_shared[abi:ue170006]() ../llvm-17.0.6-n6964152/include/c++/v1/__memory/shared_ptr.h:213:27 (example+0xed889) #2 std::__1::shared_ptr<Bar>::~shared_ptr[abi:ue170006]() ../llvm-17.0.6-n6964152/include/c++/v1/__memory/shared_ptr.h:772:23 (example+0xeb6d6) #3 get_and_release(std::__1::shared_ptr<Foo>) A.cc:38:5 (example+0xeb313)
我原以为每个线程持有智能指针副本就应该安全,哪里存在理解误区?另外我曾尝试将Bar中的std::weak_ptr<Foo> _ctx改为std::shared_ptr<Foo>,但只是降低了竞争概率,并未彻底解决问题。
问题分析与解决
核心误区
你忽略了weak_ptr的析构(包括赋值时旧对象的析构)可能触发控制块内存释放,而这个释放操作与其他线程销毁shared_ptr时的控制块读取操作没有同步。
具体来说:
- shared_ptr和weak_ptr共享同一个控制块,里面存着强引用计数(shared_count)和弱引用计数(weak_count);
- 当最后一个shared_ptr销毁时,shared_count降为0,Bar对象会被销毁,但只要还有weak_ptr指向控制块,weak_count不为0,控制块不会释放;
- 当最后一个weak_ptr销毁时(比如代码中
_ptr = sh会销毁旧的weak_ptr),weak_count降为0,控制块会被释放; - 如果此时有线程正在销毁shared_ptr
,它会读取控制块中的引用计数,而另一个线程已经开始释放控制块内存,这就造成了对同一块内存的读写竞争——TSAN报的正是这个场景。
改成shared_ptr
解决方法
方案1:让Foo持有Bar的强引用
把Foo中的std::weak_ptr<Bar> _ptr改为std::shared_ptr<Bar> _ptr,让Foo始终持有Bar的强引用,这样Bar的控制块的shared_count永远不会降为0,也就不会触发控制块的释放操作:
class Foo : public std::enable_shared_from_this<Foo> { public: std::shared_ptr<Bar> get_instance() { std::lock_guard<std::mutex> lck(m_); if (_ptr) { return _ptr; } else { _ptr = std::make_shared<Bar>(weak_from_this()); } return _ptr; } private: std::shared_ptr<Bar> _ptr; std::mutex m_; };
这种方案彻底消除了控制块被释放的场景,所有shared_ptr的销毁操作只是原子性地递减强引用计数,不会引发内存竞争。
方案2:同步weak_ptr析构与shared_ptr操作
如果必须保留weak_ptr,可以在修改_ptr前,先将旧的weak_ptr提升为shared_ptr,确保控制块的强引用计数至少为1,避免在修改过程中控制块被释放:
std::shared_ptr<Bar> get_instance() { std::lock_guard<std::mutex> lck(m_); std::shared_ptr<Bar> sh = _ptr.lock(); if (sh) { return sh; } else { // 保留旧的强引用,避免控制块在赋值过程中被释放 std::shared_ptr<Bar> old_bar = _ptr.lock(); sh = std::make_shared<Bar>(weak_from_this()); _ptr = sh; } return sh; }
这个方法通过锁的保护,确保旧控制块的强引用计数在weak_ptr析构前不会降为0,避免了释放操作与其他线程的读取操作并发。
内容的提问来源于stack exchange,提问作者Joe M

