數(shù)安全不等于對(duì)象安全)
std::shared_ptr大概是 C 里被誤解最多的類型。很多人聽說「它的引用計(jì)數(shù)是線程安全的」就放心地把它當(dāng)全局變量、在線程之間隨手賦值然后在大壓力下偶發(fā)崩潰或者計(jì)數(shù)錯(cuò)亂。真實(shí)情況要分三層看引用計(jì)數(shù)reference count確實(shí)用原子操作實(shí)現(xiàn)但shared_ptr這個(gè)對(duì)象本身不是原子的它管理的對(duì)象更不是。下面把邊界劃清楚哪些操作并發(fā)安全哪些是數(shù)據(jù)競(jìng)爭(zhēng)真要「多線程換指針」時(shí) C17 和 C20 各有什么手段。一個(gè)「偶爾」崩的線程池先看一段很常見、但確實(shí)是錯(cuò)的代碼// 反例不要這么寫數(shù)據(jù)競(jìng)爭(zhēng)Core Guidelines 要求共享可變狀態(tài)必須同步#includememorystd::shared_ptrintg_config;// 全局共享指針voidreload(){g_configstd::make_sharedint(42);// 線程 A賦值給同一個(gè)實(shí)例}voiduse(){std::shared_ptrintlocalg_config;// 線程 B拷貝同一個(gè)實(shí)例// 一旦和上面同時(shí)發(fā)生 —— 未定義行為}問題出在g_config ...和auto local g_config撞在一起前者是非 const 成員操作要改被管理指針和引用計(jì)數(shù)后者只是讀被管理指針。兩者落在同一個(gè)shared_ptr對(duì)象上就是標(biāo)準(zhǔn)意義上的數(shù)據(jù)競(jìng)爭(zhēng)data race。這里最容易混的一點(diǎn)原子化的是控制塊里的計(jì)數(shù)shared_ptr自己那兩個(gè)字段被管理指針T*和控制塊指針并不原子。賦值要同時(shí)改「指針指向」和「計(jì)數(shù)」這兩步之間沒有任何機(jī)制保證另一個(gè)線程看到的是一致狀態(tài)。所以「引用計(jì)數(shù)是原子的」這句話本身沒錯(cuò)只是它管的事情比多數(shù)人以為的少得多。控制塊control block—— 被所有 shared_ptr 副本共享 ┌───────────────────────┬───────────────────┬─────────────────┐ │ 強(qiáng)引用計(jì)數(shù)(原子) │ 弱引用計(jì)數(shù)(原子) │ 刪除器 / 分配器 │ └───────────▲───────────┴─────────▲─────────┴─────────────────┘ │ │ ┌───────┴────────┐ ┌───────┴────────┐ │ shared_ptr 實(shí)例 │ │ shared_ptr 實(shí)例 │ │ 線程 A 私有 │ │ 線程 B 私有 │ │ T* p │ ctrl* │ │ T* p │ ctrl* │ └───────┬────────┘ └────────┬───────┘ │ │ │ │ ← 不同實(shí)例各自讀改自己的兩個(gè)字段安全 └──────────┬───────────┘ ▼ ┌─────────────────────────────┐ │ 被管理的對(duì)象 T │ │ 這一層 shared_ptr 完全不管 │ │ 并發(fā)讀寫必須自己加鎖/原子化 │ └─────────────────────────────┘官方文檔std::shared_ptr — cppreferenceThread safety 一節(jié)是這篇全部結(jié)論的出處· std::memory_order — cppreference劃邊界哪些操作安全哪些是競(jìng)爭(zhēng)shared_ptr的規(guī)則可以精確到「同一個(gè)實(shí)例」還是「不同實(shí)例」操作并發(fā)情況結(jié)論多線程各自拷貝同一個(gè)const shared_ptr實(shí)例全是只讀訪問安全計(jì)數(shù)用原子操作維護(hù)不同線程持有不同的shared_ptr實(shí)例互為副本各改各的兩個(gè)字段安全標(biāo)準(zhǔn)明確保證任一線程對(duì)同一實(shí)例調(diào)用非 const 成員operator/reset/swap讀寫混在一起數(shù)據(jù)競(jìng)爭(zhēng)一個(gè)線程寫同一實(shí)例另一個(gè)線程只是拷貝它讀 非 const 寫數(shù)據(jù)競(jìng)爭(zhēng)很多人誤以為這樣安全通過p-/*p讀寫被管理的對(duì)象與shared_ptr無關(guān)要自己同步shared_ptr不提供任何保護(hù)use_count()原子讀安全但拿到的只是某一瞬間的快照不能當(dāng)同步手段一句話記憶「不同實(shí)例安全同一實(shí)例的非 const 操作危險(xiǎn)被管理對(duì)象全部自理」。正確姿勢(shì)每個(gè)線程持有自己的副本最省事、開銷也最低的做法是讓每個(gè)線程都在自己的實(shí)例上工作。線程入口按值捕獲或者拷一份局部副本// 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);// 每個(gè)線程寫自己那一格std::vectorlongcounts(kThreads,0);std::atomicintready{0};std::atomicboolgo{false};std::vectorstd::threadworkers;for(inti0;ikThreads;i){workers.emplace_back([,i]{copies[i]shared;// 拷貝構(gòu)造計(jì)數(shù)原子 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(并發(fā)期間各線程觀察到的 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());}并發(fā)期間各線程觀察到的 use_count: 5 5 5 5 副本銷毀后 use_count: 1四個(gè)線程在不同實(shí)例上各自拷貝計(jì)數(shù)被正確維護(hù)到 5副本銷毀后回到 1沒有泄漏也沒有提前釋放。copies[i] shared寫的是不同的 vector 元素彼此沒有競(jìng)爭(zhēng)。ready/go這一對(duì)原子變量純粹是為了讓輸出可復(fù)現(xiàn)否則各線程讀到的use_count取決于調(diào)度。確實(shí)要在多線程里「換指針」怎么辦有些場(chǎng)景沒法只靠副本比如配置要熱更新、緩存要整體切換。這類場(chǎng)景的共同點(diǎn)是同一個(gè)變量一個(gè)線程寫、其他線程讀這時(shí)有三種選擇。方案起始標(biāo)準(zhǔn)說明選型建議每線程持有副本C11無鎖、零額外同步成本默認(rèn)首選只在「需要看到最新值」時(shí)不適用std::atomic_load(p)/std::atomic_store(p, ...)自由函數(shù)C11C20 起 deprecated對(duì)shared_ptr做原子讀/寫C17 環(huán)境的折中方案std::atomicstd::shared_ptrTC20支持load/store/compare_exchange_*C20 環(huán)境的首選先看 C17 里能用的自由函數(shù)版本// 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));// 原子發(fā)布新版本});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返回的是一份獨(dú)立的副本所以主線程的config和snapshot各占一份計(jì)數(shù)得到 2。讀線程拿到快照之后即便寫線程緊接著替換了指針?biāo)掷锬莻€(gè)對(duì)象也保證還活著這正是這套接口存在的意義。代價(jià)是這類自由函數(shù)在 C20 里被標(biāo)記為 deprecated新代碼建議直接用下面的類型。官方文檔std::atomic(std::shared_ptr) 自由函數(shù) — cppreferenceC20 把這件事做進(jìn)了類型系統(tǒng)std::atomicstd::shared_ptrT是正式的偏特化并且提供compare_exchange_strong/compare_exchange_weak可以寫無鎖的讀-改-寫循環(huán)。下面 4 個(gè)線程各做 1000 次「讀到當(dāng)前值、發(fā)布一個(gè) 1 的新對(duì)象」最終必然是 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 是標(biāo)準(zhǔn)偏特化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;// 失敗時(shí) 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有兩點(diǎn)值得留意。一是compare_exchange_weak失敗時(shí)會(huì)順手把cur刷新成最新值所以循環(huán)體里不用重新load也不會(huì)讀到過期對(duì)象這個(gè)語義和普通 CAS 一致。二是sizeof仍是 16也就是「被管理指針 控制塊指針」沒有為原子性多付一個(gè)字的存儲(chǔ)gcc 的實(shí)現(xiàn)把自旋計(jì)數(shù)藏在了控制塊里。官方文檔std::atomicstd::shared_ptr — cppreference · std::thread — cppreference多線程共享只讀配置把上面的建議落地也就是共享數(shù)據(jù)一旦發(fā)布就不再修改各線程各拿一份副本。這是 90% 場(chǎng)景的正解也是唯一不需要鎖的方案// 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;// 拷貝構(gòu)造線程私有實(shí)例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是每個(gè)線程自己的shared_ptrconfig只被讀取、從未被寫整個(gè)程序沒有任何數(shù)據(jù)競(jìng)爭(zhēng)。Config本身也是只讀的成員函數(shù)全const構(gòu)造后不再變所以共享它不需要任何鎖。不可變數(shù)據(jù)配上引用計(jì)數(shù)是最省心的一種形態(tài)。延伸閱讀std::shared_ptr — cppreference —— Thread safety 一節(jié)逐條列出「不同實(shí)例安全 / 同一實(shí)例非 const 操作不安全」的原文措辭std::atomic(std::shared_ptr) 自由函數(shù) — cppreference ——atomic_load/atomic_store系列的全部重載與 deprecated 狀態(tài)std::atomicstd::shared_ptr — cppreference —— C20 偏特化含compare_exchange_weak的語義std::memory_order — cppreference —— 為什么作者寫acquire/release而不是默認(rèn)的seq_cstC Core Guidelines —— 并發(fā)一章的總原則共享可變狀態(tài)必須有同步能靠「不共享」解決就不要靠鎖收個(gè)尾回到開頭那句傳言。shared_ptr保證的只有一件事控制塊里的引用計(jì)數(shù)是原子的多個(gè)副本不會(huì)把計(jì)數(shù)算錯(cuò)。同一個(gè)實(shí)例上一旦出現(xiàn)非 const 操作、reset、swap同步就得你自己來被管理的對(duì)象更是從頭到尾沒人替你管。選型的順序倒是很清楚先考慮讓每個(gè)線程持有自己的副本最省事也最快C17 里確實(shí)要熱替換用std::atomic_load/std::atomic_store這對(duì)自由函數(shù)到了 C20 直接上std::atomicstd::shared_ptrT還能寫 CAS 循環(huán)。不需要熱替換就別碰原子版本多出來的開銷換不到任何東西。