news 2026/9/9 5:03:43

多线程里的 shared_ptr、引用和捕获线程安全注意点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多线程里的 shared_ptr、引用和捕获线程安全注意点

多线程里的 shared_ptr、引用和捕获线程安全注意点

引用计数本身是原子的,但指针变量、引用别名、lambda 捕获,都可能单独踩坑。本文说明什么是安全的、什么必须加锁、捕获时该拷贝还是引用。

1.std::shared_ptr<T>

一个std::shared_ptr<T>里实际有两块东西:指向 T 的指针,以及指向控制块的指针。控制块里的是引用计数。线程安全要分三层看:

  1. 控制块:强引用计数、弱引用计数的增减是原子的。
  2. shared_ptr 这个变量本身:那两个裸指针不是原子的。两个线程同时对同一个shared_ptr 对象做赋值、reset、swap,会存在数据竞争,导致未定义行为。
  3. 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();}}

如果replaceuse跑在不同线程上,这里至少有两处问题:

  1. 读写current这个变量本身没有同步,属于数据竞争
  2. if (current)current->run()之间,另一个线程可能已经 reset 了。判断时非空,用的时候已经空,或者指向一块正在析构的对象

正确解决方式:

  1. 加锁,把“读指针 + 用对象”放在同一把锁里。
  2. 先在锁内拷贝出一份局部 shared_ptr,锁外再用局部拷贝。生命周期被局部拷贝托住,但修改对象时仍存在线程不安全问题。
  3. 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 没了

这里有两层错误叠在一起:

  1. Foo& ref = *p只是别名,不增加计数
  2. 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”,不解决“怎么改内部状态”。

常见做法:

  1. T 内部自己加 mutex,所有可变接口都走这把锁
  2. 外部规定所有对 T 的访问都在同一线程

7. 注意点

异步或进线程之前:

  1. 要延长对象寿命,按值拷贝 shared_ptr,或按值捕获shared_from_this(),不要捕获 T&、不要捕获 shared_ptr&、不要捕获 this。
  2. 多个线程要换掉“当前对象”这一个指针变量,给这个变量加锁,或用atomic<shared_ptr>。不要无同步地同时读写。
  3. 锁内拷贝、锁外使用,可以缩短持锁时间;对象内部的并发修改仍要另做保护。
  4. 观察用 weak_ptr::lock(),不要用 get() 把裸指针寄到别的线程。
  5. 想清楚最后一份 shared_ptr 会在哪个线程析构,析构有没有线程亲和要求。
  6. [=][&]在异步里都过于笼统。需要什么就写什么:[p][self = shared_from_this()]
  7. use_count()if (p)之后再在没有持有局部拷贝的情况下跨线程用 p,中间都可能被别人 reset。
  8. shared_ptr 不是对象的互斥锁。数据竞争出在 T 里时,去加 T 的同步,而不是再包一层智能指针。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 5:03:37

C++26容器std::hive深度解析:性能、内存布局与选型指南

如果只盯着“std::hive 比 std::vector 快多少”这个问题&#xff0c;你大概率会得到错误结论。 先纠正一个细节&#xff1a;标题里的 std:hive 是手误&#xff0c;正确写法是 std::hive &#xff0c;它是 C26 标准库中一个等待了很久的容器提案&#xff0c;前身是开源社区…

作者头像 李华
网站建设 2026/9/9 5:03:17

猫狗检测实战:基于YOLO与VOC格式数据集的模型训练与部署指南

简介&#xff1a;目标检测是计算机视觉的核心任务之一&#xff0c;旨在识别图像中特定物体的位置与类别。其原理通常基于深度学习模型&#xff0c;通过卷积神经网络提取特征&#xff0c;并利用边界框回归与分类头实现定位与识别。这项技术在安防监控、自动驾驶、智能零售等领域…

作者头像 李华
网站建设 2026/8/30 23:09:50

PCIe/104与Coffee Lake Refresh:高性能嵌入式平台解析

把“PCIe/104”和“Coffee Lake Refresh”放在一起&#xff0c;在五年前是想都不敢想的事。一个是嵌入式工控领域的老牌板卡规格&#xff0c;以紧凑坚固著称&#xff0c;另一个是Intel为桌面游戏机准备的九代处理器。但这两年&#xff0c;这类板卡真的量产铺开了&#xff0c;而…

作者头像 李华
网站建设 2026/8/30 11:59:05

数学建模竞赛实战:从数据预处理到模型求解的Python代码精要

1. 从“电工杯”到“小代码”&#xff1a;一个数学建模竞赛的实战复盘视角 如果你参加过数学建模竞赛&#xff0c;或者对“电工杯”这个名字有所耳闻&#xff0c;那你大概能理解“小代码”这三个字背后可能蕴含的复杂情绪。它可能是一段在凌晨三点调试成功的核心算法&#xff0…

作者头像 李华
网站建设 2026/8/29 10:01:44

美国AI安全新规:最强闭源模型自愿送测,开放权重直接放行

这次我们看的不是新模型&#xff0c;也不是新的部署框架&#xff0c;而是一个会直接影响模型选型和合规路径的监管信号&#xff1a;美国 AI 安全新规框架出炉&#xff0c;最强闭源模型自愿送测&#xff0c;开放权重模型直接放行。先明确一个信息层级&#xff1a;本文不讨论这个…

作者头像 李华
网站建设 2026/8/31 0:13:25

大模型黑客松备战指南:两天打造可演示闭环Demo的完整方法论

今天打开开发者群&#xff0c;看到 MiniMaxthon 黑客松启动的消息。第一反应不是兴奋&#xff0c;而是想起很多参加过好几届黑客松的朋友经常说的一句话&#xff1a;真正开始动手前的那几个小时&#xff0c;最容易把想法做大&#xff0c;也最容易把时间耗光。尤其是这种会同时开…

作者头像 李华