多线程里的 shared_ptr、引用和捕获线程安全注意点
引用计数本身是原子的,但指针变量、引用别名、lambda 捕获,都可能单独踩坑。本文说明什么是安全的、什么必须加锁、捕获时该拷贝还是引用。
1.std::shared_ptr<T>
一个std::shared_ptr<T>里实际有两块东西:指向 T 的指针,以及指向控制块的指针。控制块里的是引用计数。线程安全要分三层看:
- 控制块:强引用计数、弱引用计数的增减是原子的。
- shared_ptr 这个变量本身:那两个裸指针不是原子的。两个线程同时对同一个shared_ptr 对象做赋值、reset、swap,会存在数据竞争,导致未定义行为。
- T 对象本身:shared_ptr 完全不管。两个线程同时改
*p,线程不安全。
std::shared_ptr<Foo>g=std::make_shared<Foo>();// 安全:每个线程拿自己的拷贝,只动计数,不动同一份 shared_ptr 变量voidthread_a(){autop=g;// 拷贝,use_count + 1,原子p->read();}// 不安全:两个线程同时写 g 这个变量voidthread_b(){g.reset();// 和别人同时写 g,数据竞争}// 不安全:对象没保护voidthread_c(){g->value+=1;// 和别人同时改 Foo,数据竞争}第一段auto p = g读的是 g 里那两个指针,然后给控制块加一。如果此时另有线程正在g.reset()改这两个指针,那么这个拷贝不是线程安全的。因此,常见的做法是用互斥锁,或借助 C++20 的std::atomic<std::shared_ptr<T>>来保护这个共享变量。
2. 同一个 shared_ptr 变量不能当共享内存
下面这种写法很常见,也很容易错:
std::shared_ptr<Foo>current;voidreplace(){current=std::make_shared<Foo>();}voiduse(){if(current){current->run();}}如果replace和use跑在不同线程上,这里至少有两处问题:
- 读写
current这个变量本身没有同步,属于数据竞争 if (current)和current->run()之间,另一个线程可能已经 reset 了。判断时非空,用的时候已经空,或者指向一块正在析构的对象
正确解决方式:
- 加锁,把“读指针 + 用对象”放在同一把锁里。
- 先在锁内拷贝出一份局部 shared_ptr,锁外再用局部拷贝。生命周期被局部拷贝托住,但修改对象时仍存在线程不安全问题。
- C++20 起用
std::atomic<std::shared_ptr<Foo>>,对指针变量做原子 load / store。这只保护指针变量,不保护 Foo 内部。
第二种是实际代码里最常用的方案:
std::mutex mu;std::shared_ptr<Foo>current;voidreplace(){autonext=std::make_shared<Foo>();std::lock_guard<std::mutex>lock(mu);current=std::move(next);}voiduse(){std::shared_ptr<Foo>local;{std::lock_guard<std::mutex>lock(mu);local=current;// 锁内拷贝,计数 + 1}if(local){local->run();// 锁外用,Foo 不会因为别人 reset 而当场销毁}}注意:local->run()期间,别的线程仍可能通过自己的 shared_ptr 在改同一个 Foo。指针活着,不等于对象内部线程安全。
C++20 的写法对应的是保护指针变量,不是保护对象:
std::atomic<std::shared_ptr<Foo>>current;voidreplace(){current.store(std::make_shared<Foo>());}voiduse(){autolocal=current.load();if(local){local->run();}}3. 引用不延长生命周期
引用、裸指针、解引用后的T&,都不影响引用计数。线程或回调只要比那个 shared_ptr 活得长,引用就悬空。
voidstart_work(std::shared_ptr<Foo>p){Foo&ref=*p;std::threadt([&ref]{ref.run();// p 可能已经销毁,ref 悬空});t.detach();}// 函数返回,p 析构;若这是最后一份,Foo 没了这里有两层错误叠在一起:
Foo& ref = *p只是别名,不增加计数- lambda 用
[&ref]捕获的是引用的引用,还是不增加计数
线程里要用这个对象,一定要把 shared_ptr按值传递:
voidstart_work(std::shared_ptr<Foo>p){std::threadt([p]{// 按值捕获,计数 + 1p->run();});t.detach();}函数参数std::shared_ptr<Foo> p已经是一份拷贝。再按值捕获一次,线程持有的是又一份拷贝。函数返回后参数析构,线程那份还在,对象就还在。异步边界上捕获 shared_ptr 的引用、捕获*p得到的T&,都会有问题。
4. lambda 捕获:按值、按引用、this
异步回调和std::thread的生命周期往往长过当前函数。捕获方式直接决定对象能不能活到回调执行。
4.1 按值捕获 shared_ptr见3
4.2 按引用捕获
autop=std::make_shared<Foo>();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_this<Foo>{voidstart(){autoself=shared_from_this();std::thread([self]{self->run();}).detach();}voidrun(){/* ... */}};autof=std::make_shared<Foo>();f->start();self是又一份 shared_ptr,线程结束前 Foo 不会析构。
C++14 起可以直接写:
std::thread([self=shared_from_this()]{self->run();}).detach();Bar 按值捕获看起来像带走了所有权,其实拷贝的是一个引用成员,还是指向原来的 Foo。p 没了,Bar 里的w一样悬空。成员里要跨线程持有对象,存 shared_ptr,不要存 T&。
5. weak_ptr:观察,不持有
跨线程“看一眼对象还在不在”,用 weak_ptr,不要用裸指针,也不要在没锁的情况下读别人的 shared_ptr 变量。
std::weak_ptr<Foo>observer=current;voidlater(){autop=observer.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。 - 多个线程要换掉“当前对象”这一个指针变量,给这个变量加锁,或用
atomic<shared_ptr>。不要无同步地同时读写。 - 锁内拷贝、锁外使用,可以缩短持锁时间;对象内部的并发修改仍要另做保护。
- 观察用 weak_ptr::lock(),不要用 get() 把裸指针寄到别的线程。
- 想清楚最后一份 shared_ptr 会在哪个线程析构,析构有没有线程亲和要求。
[=]和[&]在异步里都过于笼统。需要什么就写什么:[p]、[self = shared_from_this()]。use_count()、if (p)之后再在没有持有局部拷贝的情况下跨线程用 p,中间都可能被别人 reset。- shared_ptr 不是对象的互斥锁。数据竞争出在 T 里时,去加 T 的同步,而不是再包一层智能指针。