ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

shared_ptr 的线程安全边界:计数安全不等于对象安全

shared_ptr 的线程安全边界:计数安全不等于对象安全 std::shared_ptr大概是 C 里被误解最多的类型。很多人听说「它的引用计数是线程安全的」就放心地把它当全局变量、在线程之间随手赋值然后在大压力下偶发崩溃或者计数错乱。真实情况要分三层看引用计数reference count确实用原子操作实现但shared_ptr这个对象本身不是原子的它管理的对象更不是。下面把边界划清楚哪些操作并发安全哪些是数据竞争真要「多线程换指针」时 C17 和 C20 各有什么手段。一个「偶尔」崩的线程池先看一段很常见、但确实是错的代码// 反例不要这么写数据竞争Core Guidelines 要求共享可变状态必须同步#includememorystd::shared_ptrintg_config;// 全局共享指针voidreload(){g_configstd::make_sharedint(42);// 线程 A赋值给同一个实例}voiduse(){std::shared_ptrintlocalg_config;// 线程 B拷贝同一个实例// 一旦和上面同时发生 —— 未定义行为}问题出在g_config ...和auto local g_config撞在一起前者是非 const 成员操作要改被管理指针和引用计数后者只是读被管理指针。两者落在同一个shared_ptr对象上就是标准意义上的数据竞争data race。这里最容易混的一点原子化的是控制块里的计数shared_ptr自己那两个字段被管理指针T*和控制块指针并不原子。赋值要同时改「指针指向」和「计数」这两步之间没有任何机制保证另一个线程看到的是一致状态。所以「引用计数是原子的」这句话本身没错只是它管的事情比多数人以为的少得多。控制块control block—— 被所有 shared_ptr 副本共享 ┌───────────────────────┬───────────────────┬─────────────────┐ │ 强引用计数(原子) │ 弱引用计数(原子) │ 删除器 / 分配器 │ └───────────▲───────────┴─────────▲─────────┴─────────────────┘ │ │ ┌───────┴────────┐ ┌───────┴────────┐ │ shared_ptr 实例 │ │ shared_ptr 实例 │ │ 线程 A 私有 │ │ 线程 B 私有 │ │ T* p │ ctrl* │ │ T* p │ ctrl* │ └───────┬────────┘ └────────┬───────┘ │ │ │ │ ← 不同实例各自读改自己的两个字段安全 └──────────┬───────────┘ ▼ ┌─────────────────────────────┐ │ 被管理的对象 T │ │ 这一层 shared_ptr 完全不管 │ │ 并发读写必须自己加锁/原子化 │ └─────────────────────────────┘官方文档std::shared_ptr — cppreferenceThread safety 一节是这篇全部结论的出处· std::memory_order — cppreference划边界哪些操作安全哪些是竞争shared_ptr的规则可以精确到「同一个实例」还是「不同实例」操作并发情况结论多线程各自拷贝同一个const shared_ptr实例全是只读访问安全计数用原子操作维护不同线程持有不同的shared_ptr实例互为副本各改各的两个字段安全标准明确保证任一线程对同一实例调用非 const 成员operator/reset/swap读写混在一起数据竞争一个线程写同一实例另一个线程只是拷贝它读 非 const 写数据竞争很多人误以为这样安全通过p-/*p读写被管理的对象与shared_ptr无关要自己同步shared_ptr不提供任何保护use_count()原子读安全但拿到的只是某一瞬间的快照不能当同步手段一句话记忆「不同实例安全同一实例的非 const 操作危险被管理对象全部自理」。正确姿势每个线程持有自己的副本最省事、开销也最低的做法是让每个线程都在自己的实例上工作。线程入口按值捕获或者拷一份局部副本// shared_copy.cpp — 编译: g -stdc17 -Wall -O2 -pthread shared_copy.cpp -o sc#includeatomic#includecstdio#includememory#includethread#includevectorintmain(){constexprintkThreads4;autosharedstd::make_sharedint(7);std::vectorstd::shared_ptrintcopies(kThreads);// 每个线程写自己那一格std::vectorlongcounts(kThreads,0);std::atomicintready{0};std::atomicboolgo{false};std::vectorstd::threadworkers;for(inti0;ikThreads;i){workers.emplace_back([,i]{copies[i]shared;// 拷贝构造计数原子 1ready.fetch_add(1,std::memory_order_relaxed);while(!go.load(std::memory_order_acquire)){}// 等所有线程就位counts[i]copies[i].use_count();// 此刻 4 副本 主线程 5});}while(ready.load(std::memory_order_acquire)kThreads){}go.store(true,std::memory_order_release);for(autot:workers)t.join();std::printf(并发期间各线程观察到的 use_count: %ld %ld %ld %ld\n,counts[0],counts[1],counts[2],counts[3]);copies.clear();// 销毁全部副本std::printf(副本销毁后 use_count: %ld\n,shared.use_count());}并发期间各线程观察到的 use_count: 5 5 5 5 副本销毁后 use_count: 1四个线程在不同实例上各自拷贝计数被正确维护到 5副本销毁后回到 1没有泄漏也没有提前释放。copies[i] shared写的是不同的 vector 元素彼此没有竞争。ready/go这一对原子变量纯粹是为了让输出可复现否则各线程读到的use_count取决于调度。确实要在多线程里「换指针」怎么办有些场景没法只靠副本比如配置要热更新、缓存要整体切换。这类场景的共同点是同一个变量一个线程写、其他线程读这时有三种选择。方案起始标准说明选型建议每线程持有副本C11无锁、零额外同步成本默认首选只在「需要看到最新值」时不适用std::atomic_load(p)/std::atomic_store(p, ...)自由函数C11C20 起 deprecated对shared_ptr做原子读/写C17 环境的折中方案std::atomicstd::shared_ptrTC20支持load/store/compare_exchange_*C20 环境的首选先看 C17 里能用的自由函数版本// atomic_free.cpp — 编译: g -stdc17 -Wall -O2 -pthread atomic_free.cpp -o af#includecstdio#includememory#includethreadintmain(){std::shared_ptrintconfigstd::make_sharedint(1);std::threadwriter([config]{std::atomic_store(config,std::make_sharedint(42));// 原子发布新版本});writer.join();std::shared_ptrintsnapshotstd::atomic_load(config);// 原子读取快照std::printf(快照值 %d\n,*snapshot);std::printf(config.use_count() %ld\n,config.use_count());}快照值 42 config.use_count() 2atomic_load返回的是一份独立的副本所以主线程的config和snapshot各占一份计数得到 2。读线程拿到快照之后即便写线程紧接着替换了指针它手里那个对象也保证还活着这正是这套接口存在的意义。代价是这类自由函数在 C20 里被标记为 deprecated新代码建议直接用下面的类型。官方文档std::atomic(std::shared_ptr) 自由函数 — cppreferenceC20 把这件事做进了类型系统std::atomicstd::shared_ptrT是正式的偏特化并且提供compare_exchange_strong/compare_exchange_weak可以写无锁的读-改-写循环。下面 4 个线程各做 1000 次「读到当前值、发布一个 1 的新对象」最终必然是 4001// verify: stdc20// atomic_rmw.cpp — 需要 C20编译: g -stdc20 -Wall -O2 -pthread atomic_rmw.cpp -o armw#includeatomic#includecstdio#includememory#includethread#includevectorintmain(){constexprintkThreads4;constexprintkRounds1000;// C20 起 std::atomicstd::shared_ptrT 是标准偏特化std::atomicstd::shared_ptrintg{std::make_sharedint(1)};std::vectorstd::threadworkers;for(intt0;tkThreads;t){workers.emplace_back([g]{for(intk0;kkRounds;k){autocurg.load();for(;;){autonextstd::make_sharedint(*cur1);if(g.compare_exchange_weak(cur,next))break;// 失败时 cur 被刷新}}});}for(autot:workers)t.join();std::printf(最终值 %d\n,*g.load());std::printf(sizeof(std::atomicstd::shared_ptrint) %zu\n,sizeof(std::atomicstd::shared_ptrint));}最终值 4001 sizeof(std::atomicstd::shared_ptrint) 16有两点值得留意。一是compare_exchange_weak失败时会顺手把cur刷新成最新值所以循环体里不用重新load也不会读到过期对象这个语义和普通 CAS 一致。二是sizeof仍是 16也就是「被管理指针 控制块指针」没有为原子性多付一个字的存储gcc 的实现把自旋计数藏在了控制块里。官方文档std::atomicstd::shared_ptr — cppreference · std::thread — cppreference多线程共享只读配置把上面的建议落地也就是共享数据一旦发布就不再修改各线程各拿一份副本。这是 90% 场景的正解也是唯一不需要锁的方案// shared_config.cpp — 编译: g -stdc17 -Wall -O2 -pthread shared_config.cpp -o sconf#includecstdio#includememory#includestring#includethread#includevectorclassConfig{public:Config(std::string name,intworkers):name_(std::move(name)),workers_(workers){}conststd::stringname()const{returnname_;}intworkers()const{returnworkers_;}private:std::string name_;intworkers_;};intmain(){constexprintkThreads3;autoconfigstd::make_sharedConfig(prod,4);std::vectorstd::stringseen(kThreads);std::vectorstd::threadworkers;for(inti0;ikThreads;i){workers.emplace_back([,i]{std::shared_ptrConfiglocalconfig;// 拷贝构造线程私有实例seen[i]local-name()/std::to_string(local-workers());});}for(autot:workers)t.join();for(inti0;ikThreads;i)std::printf(线程 %d 看到 %s\n,i,seen[i].c_str());std::printf(主线程 use_count %ld\n,config.use_count());}线程 0 看到 prod/4 线程 1 看到 prod/4 线程 2 看到 prod/4 主线程 use_count 1local是每个线程自己的shared_ptrconfig只被读取、从未被写整个程序没有任何数据竞争。Config本身也是只读的成员函数全const构造后不再变所以共享它不需要任何锁。不可变数据配上引用计数是最省心的一种形态。延伸阅读std::shared_ptr — cppreference —— Thread safety 一节逐条列出「不同实例安全 / 同一实例非 const 操作不安全」的原文措辞std::atomic(std::shared_ptr) 自由函数 — cppreference ——atomic_load/atomic_store系列的全部重载与 deprecated 状态std::atomicstd::shared_ptr — cppreference —— C20 偏特化含compare_exchange_weak的语义std::memory_order — cppreference —— 为什么作者写acquire/release而不是默认的seq_cstC Core Guidelines —— 并发一章的总原则共享可变状态必须有同步能靠「不共享」解决就不要靠锁收个尾回到开头那句传言。shared_ptr保证的只有一件事控制块里的引用计数是原子的多个副本不会把计数算错。同一个实例上一旦出现非 const 操作、reset、swap同步就得你自己来被管理的对象更是从头到尾没人替你管。选型的顺序倒是很清楚先考虑让每个线程持有自己的副本最省事也最快C17 里确实要热替换用std::atomic_load/std::atomic_store这对自由函数到了 C20 直接上std::atomicstd::shared_ptrT还能写 CAS 循环。不需要热替换就别碰原子版本多出来的开销换不到任何东西。
返回列表