
多线程里的 shared_ptr、引用和捕获线程安全注意点引用计数本身是原子的但指针变量、引用别名、lambda 捕获都可能单独踩坑。本文说明什么是安全的、什么必须加锁、捕获时该拷贝还是引用。1.std::shared_ptrT一个std::shared_ptrT里实际有两块东西指向 T 的指针以及指向控制块的指针。控制块里的是引用计数。线程安全要分三层看控制块强引用计数、弱引用计数的增减是原子的。shared_ptr 这个变量本身那两个裸指针不是原子的。两个线程同时对同一个shared_ptr 对象做赋值、reset、swap会存在数据竞争导致未定义行为。T 对象本身shared_ptr 完全不管。两个线程同时改*p线程不安全。std::shared_ptrFoogstd::make_sharedFoo();// 安全每个线程拿自己的拷贝只动计数不动同一份 shared_ptr 变量voidthread_a(){autopg;// 拷贝use_count 1原子p-read();}// 不安全两个线程同时写 g 这个变量voidthread_b(){g.reset();// 和别人同时写 g数据竞争}// 不安全对象没保护voidthread_c(){g-value1;// 和别人同时改 Foo数据竞争}第一段auto p g读的是 g 里那两个指针然后给控制块加一。如果此时另有线程正在g.reset()改这两个指针那么这个拷贝不是线程安全的。因此常见的做法是用互斥锁或借助 C20 的std::atomicstd::shared_ptrT来保护这个共享变量。2. 同一个 shared_ptr 变量不能当共享内存下面这种写法很常见也很容易错std::shared_ptrFoocurrent;voidreplace(){currentstd::make_sharedFoo();}voiduse(){if(current){current-run();}}如果replace和use跑在不同线程上这里至少有两处问题读写current这个变量本身没有同步属于数据竞争if (current)和current-run()之间另一个线程可能已经 reset 了。判断时非空用的时候已经空或者指向一块正在析构的对象正确解决方式加锁把“读指针 用对象”放在同一把锁里。先在锁内拷贝出一份局部 shared_ptr锁外再用局部拷贝。生命周期被局部拷贝托住但修改对象时仍存在线程不安全问题。C20 起用std::atomicstd::shared_ptrFoo对指针变量做原子 load / store。这只保护指针变量不保护 Foo 内部。第二种是实际代码里最常用的方案std::mutex mu;std::shared_ptrFoocurrent;voidreplace(){autonextstd::make_sharedFoo();std::lock_guardstd::mutexlock(mu);currentstd::move(next);}voiduse(){std::shared_ptrFoolocal;{std::lock_guardstd::mutexlock(mu);localcurrent;// 锁内拷贝计数 1}if(local){local-run();// 锁外用Foo 不会因为别人 reset 而当场销毁}}注意local-run()期间别的线程仍可能通过自己的 shared_ptr 在改同一个 Foo。指针活着不等于对象内部线程安全。C20 的写法对应的是保护指针变量不是保护对象std::atomicstd::shared_ptrFoocurrent;voidreplace(){current.store(std::make_sharedFoo());}voiduse(){autolocalcurrent.load();if(local){local-run();}}3. 引用不延长生命周期引用、裸指针、解引用后的T都不影响引用计数。线程或回调只要比那个 shared_ptr 活得长引用就悬空。voidstart_work(std::shared_ptrFoop){Fooref*p;std::threadt([ref]{ref.run();// p 可能已经销毁ref 悬空});t.detach();}// 函数返回p 析构若这是最后一份Foo 没了这里有两层错误叠在一起Foo ref *p只是别名不增加计数lambda 用[ref]捕获的是引用的引用还是不增加计数线程里要用这个对象一定要把 shared_ptr按值传递voidstart_work(std::shared_ptrFoop){std::threadt([p]{// 按值捕获计数 1p-run();});t.detach();}函数参数std::shared_ptrFoo p已经是一份拷贝。再按值捕获一次线程持有的是又一份拷贝。函数返回后参数析构线程那份还在对象就还在。异步边界上捕获 shared_ptr 的引用、捕获*p得到的T都会有问题。4. lambda 捕获按值、按引用、this异步回调和std::thread的生命周期往往长过当前函数。捕获方式直接决定对象能不能活到回调执行。4.1 按值捕获 shared_ptr见34.2 按引用捕获autopstd::make_sharedFoo();std::threadt([p]{p-run();});t.detach();[p]只记住 p 这个变量的地址。p 是局部变量函数返回后这块栈就没了。这里崩的是 p 这个指针变量不是 Foo。4.3 捕获 this成员函数里开线程最容易写成structFoo{voidstart(){std::thread([this]{run();// 用的是裸 this}).detach();}voidrun(){/* ... */}};[this]捕获的是裸指针。Foo 被销毁后线程还在调run()就是悬空 this。如果 Foo 本身已经由 shared_ptr 管理用enable_shared_from_this在异步边界上按值捕获shared_from_this()structFoo:std::enable_shared_from_thisFoo{voidstart(){autoselfshared_from_this();std::thread([self]{self-run();}).detach();}voidrun(){/* ... */}};autofstd::make_sharedFoo();f-start();self是又一份 shared_ptr线程结束前 Foo 不会析构。C14 起可以直接写std::thread([selfshared_from_this()]{self-run();}).detach();Bar 按值捕获看起来像带走了所有权其实拷贝的是一个引用成员还是指向原来的 Foo。p 没了Bar 里的w一样悬空。成员里要跨线程持有对象存 shared_ptr不要存 T。5. weak_ptr观察不持有跨线程“看一眼对象还在不在”用 weak_ptr不要用裸指针也不要在没锁的情况下读别人的 shared_ptr 变量。std::weak_ptrFooobservercurrent;voidlater(){autopobserver.lock();// 原子地尝试把弱引用升级成强引用if(!p){return;// 已经销毁}p-run();}lock()要么得到一份有效的 shared_ptr要么得到空。不会出现“判断时还在、用的时候没了”这种把检查和使用拆开的窗口因为成功之后你已经持有强引用。缓存、观察、反向指针使用上安全。6. 对象内部仍然要自己同步即使每个线程都有一份 shared_ptr 拷贝它们指向的仍是同一个 T。下面两段可以同时发生// 线程 Ap-items.push_back(x);// 线程 Bp-items.push_back(y);这和用不用 shared_ptr 无关是std::vector的数据竞争。shared_ptr 只解决“谁来 delete”不解决“怎么改内部状态”。常见做法T 内部自己加 mutex所有可变接口都走这把锁外部规定所有对 T 的访问都在同一线程7. 注意点异步或进线程之前要延长对象寿命按值拷贝 shared_ptr或按值捕获shared_from_this()不要捕获 T、不要捕获 shared_ptr、不要捕获 this。多个线程要换掉“当前对象”这一个指针变量给这个变量加锁或用atomicshared_ptr。不要无同步地同时读写。锁内拷贝、锁外使用可以缩短持锁时间对象内部的并发修改仍要另做保护。观察用 weak_ptr::lock()不要用 get() 把裸指针寄到别的线程。想清楚最后一份 shared_ptr 会在哪个线程析构析构有没有线程亲和要求。[]和[]在异步里都过于笼统。需要什么就写什么[p]、[self shared_from_this()]。use_count()、if (p)之后再在没有持有局部拷贝的情况下跨线程用 p中间都可能被别人 reset。shared_ptr 不是对象的互斥锁。数据竞争出在 T 里时去加 T 的同步而不是再包一层智能指针。