ARTICLE DETAIL

资讯详情

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

oneTBB 并发设计模式之 Fenced Data Transfer:用 std::atomic 栅栏构建可靠的跨线程数据发布

oneTBB 并发设计模式之 Fenced Data Transfer:用 std::atomic 栅栏构建可靠的跨线程数据发布 并发编程高性能计算【免费下载链接】oneTBBoneAPI Threading Building Blocks (oneTBB)项目地址https://gitcode.com/gh_mirrors/on/oneTBB点击查看免费下载导读本指南讲解 oneTBBoneAPI Threading Building Blocks设计模式库中的Fenced Data Transfer带栅栏的数据传输模式。该模式解决一个经典并发难题在非顺序一致non-sequentially consistent内存模型的硬件上如何安全地写消息 → 标记就绪 → 对方读取保证写入与读取的可见性顺序。读完本文你将掌握 release/acquire 栅栏语义、如何用std::atomic修复标志位 数据惯用法以及为什么volatile、条件执行假设和指针读取假设都不能解决该问题同时结合 oneTBB 源码如 mailbox.h、arena.h 与测试工具 spin_barrier.h看到这一模式在真实任务调度器中的落地形态。Problem在非顺序一致内存模型上传递消息在单核处理器上线程 A 写一段消息线程 B 读取是一个非常朴素的需求只要写完再读数据必然正确。但当程序运行在不具有顺序一致sequentially consistent内存模型的多处理器硬件上时事情就变得危险了。问题的完整表述是把一条消息写入内存并让另一个处理器在不具备顺序一致内存模型的硬件上读取到它。这里的读取到不仅指读到值还指读到正确的顺序——接收方必须先看到就绪标志然后才能安全地读取消息内容。如果两者顺序颠倒接收方可能读到未写完的、半更新的消息数据。Context重排序的来源与标志位 数据惯用法为什么会出现乱序该问题通常只在以下场景出现未同步unsynchronized的线程并发操作同一内存位置线程直接用裸的读写操作来构造同步关系即自己手工实现同步原语而不是使用现成的高层同步构造。现代硬件和编译器会做大量的优化编译器会按自己的规则重排内存操作、合并或延迟写入处理器则可能通过乱序执行、写缓冲store buffer等手段改变内存操作对外可见的顺序。重排的前提是从该线程自身的视角看操作顺序保持不变——也就是说单线程观察不到重排但其他线程观察得到。这正是问题所在发送方视角的先写消息、后置标志在接收方眼中可能变成标志已置、消息未写完。串行惯用法写消息 就绪标志这是串行编程中最常见、也最危险的同步惯用法用数据旁边的一个bool标志表示数据已就绪。bool Ready; std::string Message; void Send( const std::string src ) { // Executed by thread 1 Message src; Ready true; } bool Receive( std::string dst ) { // Executed by thread 2 bool result Ready; if ( result ) dst Message; return result; // Return true if message was received. }这段代码隐含两个关键假设在Message写完之前Ready不会变为 true发送侧的写入顺序在Ready变为 true 之前Message不会被读取接收侧的读取顺序。这两个假设在单处理器硬件上天然成立——因为所有线程共享同一执行流任何乱序都不会被其他线程感知。但在多处理器上两者都可能被破坏硬件或编译器把发送方的写操作重排导致接收方看到Ready已为 true 但Message尚未写完破坏假设 1接收方自身的读操作被重排导致在读取Ready之前就先读了Message破坏假设 2。Forces通过裸读写创建同步的诱惑与陷阱本模式的核心驱动力forces是通过原始的内存读写来创建同步关系——即不用锁、不用信号量而是希望写标志位这种廉价操作本身就能充当同步屏障。这种做法的吸引力在于成本极低bool的读写几乎免费。但它要求对内存模型有精确的把握稍有疏忽比如用volatile见下文 Non Solutions就会得到看起来正确、实际是未定义行为的代码。Solution把标志位改成std::atomicbool用 release/acquire 栅栏修复方案非常简单直接把就绪标志从bool改为std::atomicbool并在写入时使用std::memory_order_release读取时使用std::memory_order_acquire。std::atomicbool Ready; std::string Message; void Send( const std::string src ) { // Executed by thread 1 Message src; Ready.store(true, std::memory_order_release); } bool Receive( std::string dst ) { // Executed by thread 2 bool result Ready.load(std::memory_order_acquire); if ( result ) dst Message; return result; // Return true if message was received. }改动只有三处语义却发生了根本变化Ready的类型变为std::atomicbool发送侧的Ready true;变为Ready.store(true, std::memory_order_release);接收侧的bool result Ready;变为bool result Ready.load(std::memory_order_acquire);。release 与 acquire 语义的精确含义release 写store with release semantics对std::atomic值的释放性写入保证该写入之前的所有写入操作都会在释放写被其他线程看到之前变得可见。换言之Ready.store(true, release)之前对Message的赋值必然先于Ready true对接收方可见。acquire 读load with acquire semantics对std::atomic值的获取性读取保证该读取之后的所有后续读操作都会在该获取读完成之后发生。换言之一旦接收方看到Ready trueacquire 读完成它之后对Message的读取就必然发生在这之后不会读到旧值。std::atomic的实现会确保编译器和硬件都遵守这些顺序约束——它不仅会指示编译器不要重排相关操作还会在需要时插入恰当的硬件栅栏指令如 x86 上的mfence/锁指令前缀、ARM 上的dmb等具体取决于目标平台。因此 release/acquire 形成了一道完整的栅栏fence数据在 release 点之前全部就位在 acquire 点之后才能被安全读取。这正是本模式名称Fenced Data Transfer的由来。边界情况说明需要澄清的是release/acquire 提供的是单向顺序约束。它只保证release 之前的写先于 acquire 之后的读可见并不提供全序或std::memory_order_seq_cst那样的全局一致顺序。对于本模式要解决的先发布数据、再通知就绪问题这一约束已经足够且比seq_cst在多数架构上开销更低。Variations高层同步构造内置了 acquire/release 栅栏本模式有一个重要的变体结论更高层的同步构造通常已经内置了必要的 acquire/release 栅栏。例如**互斥锁mutex**的实现惯例是获取锁具有acquire 语义加锁成功后锁保护的内存写入对其他线程可见释放锁具有release 语义解锁之前的所有写操作都会在解锁后对其他线程可见。由此可以推出一个实用的保证一个线程在获得某把锁之后总能看到另一个线程在释放同一把锁之前所做的所有内存写入。也就是说锁的加锁/解锁本身就构成了发布-获取publish-subscribe通道消息传递模式中的栅栏无需手工编写。这条变体提醒开发者优先使用现成的高层同步原语互斥锁、读写锁、条件变量、oneTBB 提供的spin_mutex、queuing_mutex、task_group、concurrent_queue等它们帮你把栅栏放到了正确的位置只有当性能敏感、需要极轻量同步时才手工使用本模式。Non Solutions为什么这些理所当然的修法都是错的错误但看起来合理的修法如此常见以至于值得逐一剖析它们错在哪里。这有助于你在代码评审中识别出隐患。误区一volatile关键字一个常见的错误是认为把标志位声明为volatile就能解决问题volatile bool Ready; // 错误不能修复可见性问题volatile的作用是强制写入立即发生禁止编译器把该变量的读写优化掉、合并或延迟但它对该写操作相对于其他内存操作的可见顺序一般没有任何影响。volatile不产生栅栏不阻止编译器和硬件重排其周围的其他内存操作更不提供跨线程的 happens-before 关系。事实上在现代 C 中正确做法是使用std::atomic把volatile当作同步工具是典型的误用。值得说明的是volatile与原子操作是正交概念std::atomic保证原子性 顺序volatile只保证不被优化掉两者都不可互相替代C20 中volatile原子操作甚至被进一步限制。误区二条件执行代码不会跑到条件判断之前另一个错误假设是被条件执行的代码不可能在条件被测试之前执行。bool result Ready; if ( result ) dst Message; // 假设 Message 的读取必然在 Ready 读取之后这个假设对理想化的顺序执行机器成立但编译器或硬件可能把条件内的代码投机性地提前speculatively hoist到条件判断之上。处理器会做分支预测与投机执行当它预测result为 true 时可能提前读取Message。由于投机执行的结果可能被缓存到缓存行中见误区三这种提前读取确实可能发生并被观察到。误区三处理器不会先读指针目标再读指针还有一个隐蔽的误解处理器不可能在读取指针本身之前先读取指针指向的目标。现代处理器并不是从主存中逐个读取单个值而是以**缓存行cache line**为粒度进行读取。指针的目标可能恰好位于一个在指针被读取之前就已经被读入缓存的缓存行中。因此即使处理器逻辑上先读指针、后读目标由于缓存行早已驻留从外部观察者的视角看目标值似乎被先见之明地提前读取了。这再次说明程序逻辑顺序 ≠ 硬件访问顺序必须依赖显式的栅栏约束。源码佐证oneTBB 内部如何落地这一模式Fenced Data Transfer 不仅是文档中的模式更是 oneTBB 调度器内部的日常工作方式。以下三处源码展示了 release/acquire 栅栏在真实代码中的运用。任务信箱mailbox.honeTBB 的任务信箱mail outbox机制用于在不同线程/arena 之间传递任务代理task_proxy这正是典型的生产-消费 数据发布场景其实现大量使用 acquire/releasesrc/tbb/mailbox.h 中task_proxy用std::atomicintptr_t task_and_tag原子地管理代理位于任务池还是信箱的状态src/tbb/mailbox.h 中提取任务时使用task_and_tag.load(std::memory_order_acquire)确保拿到任务指针前其相关的发布数据已可见src/tbb/mailbox.h 中投递任务时使用link-store(t, std::memory_order_release)保证t所指向的任务在链入信箱之前已完全初始化——与本文模式中的Ready.store(true, release)如出一辙src/tbb/mailbox.h 中轮询等待时用next_in_mailbox.load(std::memory_order_acquire)获取新到达的代理。这是一套教科书式的写者 release 发布、读者 acquire 获取的 mailbox 实现。Arena 状态与引用计数arena.hArena执行区域多个线程共享的工作环境的状态与引用计数也依赖 acquire 加载src/tbb/arena.h 中通过my_state.load(std::memory_order_acquire)读取 arena 状态以判断其执行条件src/tbb/arena.h 中references()用my_references.load(std::memory_order_acquire)读取外部引用计数。这些都属于读取由其他线程发布的状态的 acquire 用例与本模式接收侧一致。测试工具spin_barrier.h测试基础设施同样贯彻了这一模式。test/common/spin_barrier.h 提供的SpinWaitWhileCondition/SpinWaitUntilEq自旋等待工具内部统一使用location.load(std::memory_order_acquire)读取被等待的原子位置template typename T, typename C void SpinWaitWhileCondition(const std::atomicT location, C comp) { SpinWaitWhile([] { return comp(location.load(std::memory_order_acquire)); }); }自旋等待通常要配合 release 发布方使用发布线程store(..., std::memory_order_release)等待线程load(std::memory_order_acquire)二者构成 release-acquire 对。相关的并发测试如 test/tbb/test_task.cpp、test/tbb/test_scheduler_mix.cpp也大量使用显式内存序来保证测试自身状态同步的确定性。互斥锁实现的印证本模式Variations一节关于互斥锁具备 acquire/release 语义的论断也可以在 oneTBB 的锁实现中找到印证例如 src/tbb/rtm_mutex.cpp 与 src/tbb/queuing_rw_mutex.cpp 内部均使用memory_order_acquire/memory_order_release控制锁状态的发布与获取。实践建议与总结优先用现成同步原语多数场景下互斥锁、oneTBB 的并发容器与task_group等已经内置了正确的栅栏不需要手工实现本模式。必须手工发布-获取时用std::atomic release/acquire数据写入在前store(..., std::memory_order_release)发布标志读取方load(std::memory_order_acquire)确认标志后再读数据。识别并消除三种错误修法volatile不提供顺序保证条件执行不能阻止投机提前缓存行粒度读取使先读指针再读目标的直觉不可靠。注意边界release/acquire 提供的是发布-获取单向约束不是全局顺序。需要全序或更强的原子操作如计数器、引用计数时应选用std::memory_order_acq_rel/seq_cst或直接使用 oneTBB 的spin_mutex、atomic封装等现成设施。Fenced Data Transfer 模式揭示了一个并发编程的核心事实在共享内存多处理器上顺序是一种需要显式购买的商品。oneTBB 的文档将其提炼为独立模式参见 Design_Patterns.rst 的设计模式总览与 General_References.rst 中的参考文献而调度器源码则在信箱、arena 与自旋等待中反复应用它——理解这一模式是读懂 oneTBB 内部机制、写出正确并发代码的第一步。同系列模式还包括 Lazy_Initialization.rst、Reference_Counting.rst 与 Local_Serializer.rst可对照阅读以形成完整的并发设计图景。赞分享并发编程高性能计算【免费下载链接】oneTBBoneAPI Threading Building Blocks (oneTBB)项目地址https://gitcode.com/gh_mirrors/on/oneTBB点击查看免费下载相关推荐图说设计模式之并发编程多线程环境下的设计模式应用图说设计模式之并发编程多线程环境下的设计模式应用 你是否还在为多线程程序中的资源竞争焦头烂额是否在面对复杂并发场景时不知如何设计优雅的解决方案本文将通过图文档教程Opsweekly安全配置保护你的值班数据隐私和访问控制终极指南 Opsweekly安全配置保护你的值班数据隐私和访问控制终极指南 Opsweekly是一个强大的值班警报分类和报告工具它帮助团队跟踪值班轮换、生成周报后端Java并发编程架构设计如何构建高并发分布式系统Java并发编程架构设计如何构建高并发分布式系统 在当今互联网时代 高并发分布式系统 已成为企业级应用的标配。掌握Java并发编程架构设计不仅能让你的系统处文档教程知识库上一篇鸣潮画质帧率优化与抽卡分析全攻略WaveTools工具箱实战指南下一篇4招告别鸣潮卡顿与抽卡焦虑WaveTools鸣潮工具箱实用操作手册创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表