公司动态

[C++20 并发安全] 彻底告别死锁与数据竞争!一文看懂 C++ 如何借鉴 Redux 实现不可变状态树 | CppCon 精华

📅 2026/7/14 22:31:18
[C++20 并发安全] 彻底告别死锁与数据竞争!一文看懂 C++ 如何借鉴 Redux 实现不可变状态树 | CppCon 精华
导读摘要多线程读写引发的数据竞争和繁杂互斥锁带来的死锁崩溃是否让你对并发编程敬而远之本文基于 CppCon 2020 Borislav Stanimirov 的演讲“No Touchy! A Case Study of Software Architecture with Immutable Objects”用**“共享云文档的历史版本”与“复印照相机”等通俗生活类比为你解密如何将 Web 核心开发中的 Redux 架构和函数式编程的不可变状态Immutability**成功引入 C 医疗设备系统中。文章深入剖析了结构共享Structural Sharing的内存优化、C20 强力特化std::atomicstd::shared_ptrT的原子发布技术并结合内存碎片化评估了不可变架构的优缺点折中。核心收获学会设计“读写分离、无锁读取”的高并发安全企业级软件架构。 前言高并发医疗设备中的“死锁地狱”想象一下你正在为一台放疗癌症设备编写控制系统。这个系统极其庞大需要同时和核磁共振MRI、线性加速器发放射线、前端 GUI、数据库多个线程高速通信。在传统的面向对象设计中我们习惯于到处传递指针或引用直接就地修改In-place mutation数据// ❌ 传统共享可变状态容易发生致命的并发冲突voidupdate_patient_age(Patient*patient,intnew_age){patient-agenew_age;// 如果此时另一个线程正在读取 patient-age 进行放疗剂量计算……}为了不让设备崩溃你不得不加上成百上千个互斥锁std::mutex。然而锁加得少 ⟹数据竞争Data Race报错频发。锁加得多 ⟹ 性能暴跌稍不留神就陷入死锁地狱DeadlockUI 卡死。有没有一种办法能让多线程安全地读取数据同时完全不需要任何互斥锁ViewRay 公司的技术专家 Borislav Stanimirov 在 CppCon 2020 上给出了他们的答案“No Touchy!” 谁都不要直接修改数据我们用“不可变状态树” 第一章不可变对象与状态树架构生活类比就地涂改 vs 拍照片[!TIP]可变对象Mutable就像在黑板上写字。任何人都可以用粉笔擦擦掉并重写就地修改。如果多个人同时去写字迹就会混乱不堪。不可变对象Immutable就像是拍照片“No Touchy!”。照片一旦拍好内容就凝固了任何人拿去怎么看都不会改变。如果想改那就在照片的基础上做点修改然后重新拍一张新照片。状态树 (State Tree) 模型系统的所有状态全部集中在一棵由std::shared_ptrconst T连结而成的单向状态树中[ Root State ] (SystemState) / \ [ PatientState ] [ MachineState ]所有的节点都是const的外部模块只能通过get()方法读取没有任何set()方法。这从根本上保证了多线程并发读取该树不需要任何锁因为根本没有写冲突 第二章如何更新不可变数据结构共享与路径拷贝既然数据是只读的我们要怎么修改它比如患者的名字改了我们要怎么更新系统答案是结构共享Structural Sharing和路径拷贝Path Copying。2.1 路径拷贝算法解析如果我们只修改了PatientState中的Name节点我们不需要把整个MachineState也拷贝一遍。(旧树) (新树) [ Root State ] [ Root State ] / \ / \ [PatientState] [MachineState] [PatientState] [MachineState] (直接共享指针) | | [Name] [Name]新建Name节点包含修改后的新名字。复制PatientState节点生成PatientState并将其子节点指针指向Name。复制根节点Root State生成Root State并指向新生成的PatientState。核心优化未被修改的MachineState及其子树直接被新树共享指针std::shared_ptr的引用计数加 1 即可完全不需要拷贝这样我们只用极小的开销就获得了一个全新的系统状态树Root State同时旧的Root State依旧完好无损。️ 第三章多线程下如何原子发布新状态写入线程在后台生成了新状态树Root State之后如何通知所有的读取线程这就需要通过一个单写入者Single Writer模型来原子地替换根状态指针。// 多个读取线程autocurrent_stateglobal_store.load();// 零延迟立刻获得当前系统快照// 即使写入线程此后发布了新状态current_state 依然有效并且内容永远不变3.1 C20 现代武器std::atomicstd::shared_ptrT在 C11/17 中原子读写shared_ptr必须依赖极其别扭且已被废弃的全局函数// ❌ C11 废弃的原子操作函数易错且不直观autocurrentstd::atomic_load(global_root);std::atomic_store(global_root,new_root);C20引入了对std::shared_ptr的std::atomic特化使得状态原子发布变得浑然天成#includeatomic#includememorystructSystemState{std::string patient_name;intmachine_voltage;};// C20声明原子共享指针std::atomicstd::shared_ptrconstSystemStateglobal_state;// 读取端 (Lock-Free Read)voidreader_thread(){// 无锁、原子地加载当前状态快照std::shared_ptrconstSystemStatestateglobal_state.load();std::coutPatient: state-patient_namestd::endl;}// 写入端 (Single-Thread Writer)voidwriter_thread(){autoold_stateglobal_state.load();// 拷贝并修改 (CoW)autonew_statestd::make_sharedSystemState(*old_state);new_state-patient_nameBob;// 原子发布新状态global_state.store(new_state);}[!IMPORTANT]std::atomicstd::shared_ptrT避免了手动编写无锁容器或使用信号量的麻烦它是高并发“读写分离”软件设计的一大利器。️ 第四章如何在 C 中构建不可变节点要确保节点“不可修改”我们可以采取两种模式4.1 模式 AConst 成员变量物理级不可变structImmutablePerson{conststd::string name;constintage;// 变换函数返回修改后的新对象std::shared_ptrconstImmutablePersonwith_age(intnew_age)const{returnstd::make_sharedconstImmutablePerson(name,new_age);}};优点编译器从物理上禁止你修改成员绝对安全。缺点无法提供拷贝赋值运算符对某些 STL 容器不太友好。模式 BGetters-only 封装逻辑级不可变classImmutablePerson{std::string name_;intage_;public:ImmutablePerson(std::string name,intage):name_(std::move(name)),age_(age){}// 只读接口std::stringname()const{returnname_;}intage()const{returnage_;}// 通过 Mutable 拷贝进行修改std::shared_ptrconstImmutablePersonwith_name(std::string new_name)const{autocopystd::make_sharedImmutablePerson(*this);// 在局部构造时是可以改变的copy-name_std::move(new_name);// 修改临时变量returncopy;// 发布为只读指针}}; 第五章优缺点权衡Trade-offs—— 架构是折中的艺术Borislav 在演讲中明确指出世界上没有免费的午餐不可变状态树架构也有其必须面对的代价维度传统共享可变状态OOP不可变状态树FP-like并发安全❌ 极差容易发生数据竞争与死锁极佳读无锁天然线程安全内存开销低就地修改零拷贝❌ 高每次修改都需要分配小对象撤销/重做❌ 复杂需要手动实现 Command 模式极易直接保存 Root 历史指针测试难度❌ 难需要复杂的并发多线程 Mock极易零副作用只关注输入输出数据关系自由支持网状、环状双向链表❌ 受限只能是 DAG/树禁止循环引用5.1 性能优化药方针对“堆内存频繁分配Memory Churn”的缺点ViewRay 团队给出的优化方案是替换默认内存分配器不要使用系统默认的malloc建议替换为高效的并发内存分配器如Microsoft mimalloc或jemalloc能获得数倍的分配性能提升。状态合批更新在单次事务中批量修改多个节点只在最终提交时更新 Root 根指针。 第六章专家深潜 —— 视频之外的不可变设计6.1 时光倒流调试Time-travel Debugging原理不可变状态树在调试复杂并发程序时极具优势。因为只要保留每一个历史版本的 Root 指针就等于保留了那个瞬间整台机器的物理快照。// 用 std::vector 记录系统历史状态std::vectorstd::shared_ptrconstSystemStatestate_history;voidon_state_changed(std::shared_ptrconstSystemStatecurrent){state_history.push_back(current);// 仅仅拷贝指针开销极低}// 出现异常时直接查看某一步的快照voidroll_back_to(size_t frame_index){autohistorical_statestate_history[frame_index];// 瞬间让整台机器的状态恢复到 frame_index 那个时刻}6.2 不可变性与 C 编译器优化Compiler Optimizations当成员变量或方法是const时编译器能够做更多的激进优化常量折叠Constant Folding编译器确信对象内容不会被修改因此会将读取结果直接替换为编译期常量。无名临时变量优化编译器更容易将拷贝省略Copy Elision减少实际上发生的内存拷贝。️ 不可变状态树更新思维导图触发修改 (如: 修改患者名字)1. 拷贝叶子节点 (Name)2. 拷贝父节点指向新叶子 (PatientState)3. 拷贝根节点 (Root State)4. 共享未修改子树 (MachineState)5. 原子替换全局根指针 (global_state.store) 总结不可变架构的三大设计戒律No Touchy!一旦发布std::shared_ptrconst T绝不对其内部进行任何方式的修改。单向数据流只有唯一的写入线程在后台处理路径拷贝其余读取线程通过原子加载无锁获取快照。结构共享至上合理划分状态树层级确保每次更新只拷贝发生变化的最小路径。SEO 长尾关键词布局C 不可变对象设计、React Redux 架构 C 实现、std::atomic shared_ptr C20、结构共享与路径拷贝算法、无锁多线程并发 C 架构、ViewRay 医疗设备 C 架构方案、mimalloc 优化 make_shared视频来源CppCon 2020 - No Touchy! A Case Study of Software Architecture with Immutable Objects - Borislav Stanimirov本文作者立场以 C 标准专家视角结合视频内容与实际开发经验撰写。如有疑问欢迎评论区讨论