资讯动态

零堆零拷贝:事件只构造一次,全程只传指针不复制

发布时间:2026/10/16 2:51:28 来源:尧图企业网站定制
零堆零拷贝事件只构造一次全程只传指针不复制事件驱动框架里的零拷贝与 DMA 无关事件对象与 payload 只在固定池中构造一次后续提交、排队、派发和回收全程传递同一个Event*。零堆分配提供稳定地址引用计数管理共享生命周期所有权转移则规定谁能继续访问。事件系统中的零拷贝边界先划清边界。传统零拷贝指网卡/磁盘 DMA 绕过 CPU 把数据直写到用户态缓冲区省的是内核态到用户态的memcpy。coact 是面向 RT-Thread 单核 MCU 的 header-first C17 事件框架github.com/DeguiLiu/coact它要处理的拷贝在别处一个事件从 ISR/任务投递到目标活动对象Active ObjectAO手里中间要经过协调器、暂存队列、派发器好几站如果每一站都把事件或 payload 复制一份MCU 的 RAM 和 CPU 都不够用。coact 的答案是把复制事件这个动作彻底删掉靠三件事事件池让对象只有唯一一块存储引用计数让多个目标共享同一块而不复制整条链路只传指针。生产者alloc() 拿到 Event*DispatchCoordinatorsubmit()直发路径?Staging 暂存队列只存 Event*Dispatcher批处理出队AOHsm::dispatchevent_gc引用归零回收图中分岔对应直发的两个前提目标 AO 空闲执行租约处于Idle没有线程正在跑它且调用方不在派发线程派发线程内直发会在同一栈上内联执行栈深无界。任一不满足就入队。稳定存储与共享生命周期零拷贝要成立先得保证事件有一块稳定、唯一的存储。如果每次投递都malloc一块新内存再memcpy内容那恰恰是有拷贝。coact 用EventPool固定容量事件池把这块存储预先锁定。池里的每个事件块就是一个带头的原始字节区事件头Event只有四个字段struct Event { uint16_t signal; // Event signal. uint8_t pool_id; // 0 for static events; otherwise a 1-based pool index. uint8_t ref_ctr; // Starts at 1; extra posts increment it; zero reclaims. };EventPool::init(void* storage, size_t bytes)接收一块外部静态缓冲区按固定步长切成若干块用空闲链表串起来。分配时alloc(signal)在空块上 placement-new 出Event返回Event*。这块存储在整个生命周期里地址不变所以指针可以安全地在 ISR、任务、派发线程之间传递谁拿到都指向同一块物理内存。对带 payload 的事件alloc_typed用组合布局把事件头、应用元数据、payload 塞进同一个块template typename Meta, size_t PayloadBytes, size_t PayloadAlign struct EventBlockLayout { Event event; Meta meta; alignas(PayloadAlign) std::byte payload[PayloadBytes]; };事件头和 payload 连续存放一次 allocation 就位之后移动指针即移动整个事件无需任何二次拷贝。池的空闲链表头是一个 32 位压缩字低 16 位是空闲块索引高 16 位是 ABA 标签。每次分配/回收都让标签自增即使发生 ABA 竞争陈旧操作也不会误判头节点。同步后端在编译期二选一RttSingleCoreProfile单核 100MHz 产品claim/reclaim/used/高水位全部在一条关中断临界区内完成不做 CAS、不带 ABA 标签回退。HostSmpProfileSMP/TSan 验证语义带 ABA 标签的 CAS acquire/release 发布要求 32 位无锁 CAS热点以 32 字节 cache line 隔离。无论哪种后端池的公共生命周期语义一致只是内部同步算法不同。这个池是零拷贝的物理前提块地址稳定指针才敢跨线程、跨 ISR 传递而不失效。引用计数让多个目标共享同一事件零拷贝的第二个机制是引用计数reference counting。一个事件要同时投递给多个 AO 时朴素的实现会为每个目标复制一份。coact 不复制它让所有目标共享同一个Event*用ref_ctr计数每多投递一个目标event_ref_inc加一每个消费者处理完event_gc减一最后一个减到零的那个消费者把块还给池。inline void event_ref_inc(Event* e) noexcept { if (e-pool_id ! 0U) { // Static events are never reclaimed. e-ref_ctr; } } inline void event_gc(Event* e) noexcept { if (e-pool_id 0U) return; // Static event. if (--e-ref_ctr 0U) { record-reclaim(e); // Return the zero-ref block to the free list. } }pool_id 0表示静态事件编译期就存在的常量事件它不来自池、也永不回收。这个区分让零堆和池回收两条路径互不干扰。allocref_ctr 1event_ref_inc ×2ref_ctr 3两个消费者各 event_gcref_ctr 1owner event_gcref_ctr 0回收到 free list引用计数带来最后一个消费者负责回收的语义投递方把所有权转移出去之后就不能再碰这个事件否则会 double free。coact 在协调器接口上明确了这个契约submit_*返回后调用方不得再访问事件。从提交到回收只传递指针有了唯一的池块和引用计数就可以把传指针贯彻到底。看暂存队列的槽位结构StagingSlotstruct StagingSlot { TargetId target; Event* event; // Owned reference transferred to the dispatcher. uint64_t enqueue_ns; // Enqueue time used for low-priority aging. };整个暂存队列里存的是Event*加目标 id 和时间戳不是事件内容。事件从协调器submit()入队到派发器出队到 AO 的Hsm::dispatch处理再到最后event_gc回收这条链路上流转的一直是这个指针。payload 始终停在池块里未移动。这里要区分两个复制。队列在 push/pop 时确实移动了StagingSlot本身但那是三个标量字段指针 目标 时间戳的 move是固定几字节的指针搬运不是事件数据的复制。零拷贝指的是事件 payload 零复制不是连指针都零移动。直发路径省去队列与线程切换队列路径已经零拷贝但 coact 还有一条更短的路径直发direct dispatch。当目标 AO 空闲、调用方不在派发线程、且嵌套深度未超限时协调器不入队直接在调用栈上跑完目标 handler。// coordinator.hpp: direct-dispatch predicate, excerpt. if (!from_isr !force_staging !PalT::in_dispatcher_thread() (pal_.current_context().direct_depth StagingT::ConfigType::kMaxDirectDepth) ao-direct_eligible() target_breaker.direct_allowed(target)) { if (AoRunState::Idle ao-lease().state()) { pal_.enter_direct(); const bool taken ao-dispatch_direct(*e); // Run the handler in place. pal_.leave_direct(); if (taken) { event_gc(e); // Reclaim after completion. return {SubmitDisposition::Direct, 0U}; } } }直发路径里事件既不入队也不排空从submit()到 handler 执行之间没有中间存储也没有任何一次复制。它把零拷贝做到最短路径连指针在队列里的进出都省了。这条路径只在任务上下文、目标空闲时成立ISR 或竞争失败时自动回落到队列路径语义不变。直发省掉的不只是拷贝。对比两条路径的完整步骤队列路径要enqueue入队、request_dispatcher_wake触发唤醒、PAL 发信号、派发线程醒来后dequeue出队、再抢RunningDispatcher租约、最后跑 handler直发路径只有enter_direct、dispatch_direct、event_gc三步没有跨线程交接也没有唤醒信号。所以直发既省一次队列进出也省一次线程调度对实时控制类事件是关键的确定性来源。直发只是协调器submit()的几种去向之一。完整的SubmitDisposition枚举列出所有去向Direct直发、Queued入队、Merged合并v1 未实现、DroppedPolicy/DroppedRateLimit/DroppedOverload按策略/限流/过载丢弃、RejectedFull/RejectedState队列满/目标未绑定拒绝。无论落到哪条路径被接受的事件都只按指针流转丢弃和拒绝时也只是event_gc一次不存在复制一份再处理的分支。有界队列只搬运句柄即便走队列路径coact 的队列本身也设计成槽位就地构造、无堆。三个队列后端共享同一个思路预分配alignas对齐的原始字节槽push 时 placement-new 构造对象pop 时std::move取走再析构。// Slot storage shared by queue.hpp and spsc_ring.hpp. template typename T struct alignas(alignof(T)) SlotStorage { std::byte bytes[sizeof(T)]; };BoundedMpscQueueSMP 多生产者单消费者、SingleCoreCriticalRing单核中断临界区保护、SpscRing无锁单生产者单消费者三个后端的槽位里放的都是StagingSlot这样的轻量句柄。pop 时的std::move搬走的是几个标量字段事件数据仍在池块里未移动。三个后端解决同一个问题有界、无堆、只搬句柄但同步手段按场景分化// BoundedMpscQueue cell: Writing is exclusive, then published as Ready. template typename T struct alignas(32) MpscCell { SlotStorageT storage; std::atomicuint32_t state{0U}; // Free / Writing / Ready / Consuming. std::atomicuint64_t ticket{0U}; };BoundedMpscQueue面向 SMP 多生产者单消费者。每个单元有一个四态原子state生产者先 CAS 抢占一个 Free 单元进入 Writing构造完 payload 再发布为 Ready消费者扫描固定单元集取最早 Ready 的发布。全程无锁。SingleCoreCriticalRing面向单核 ISR 生产者注入关中断临界区rt_hw_interrupt_disable/enable在临界区内完成容量检查、payload move、水位读取一步搞定不做 CAS。SpscRing是无锁单生产者单消费者环用两条单调递增的uint16_t序号生产者 head、消费者 tail对 2 的幂个槽取模release 发布、acquire 观察满条件head - tail Capacity不浪费哨兵槽。这个设计还带来一个副产品因为槽位是编译期定容的原始字节队列全程不碰malloc/free配合事件池整条链路实现了零堆分配。MCU/SoC 上既省了拷贝的开销也消除了堆碎片和分配失败这两个实时隐患。所有权契约、容量边界与代价零拷贝不是免费的它的成立建立在几个硬约束上。事件必须 trivially copyable 且 trivially destructible。池回收时不会调用 payload 的析构函数因为回收路径只有把块链回空闲链表没有析构对象这一步。所以放进池的事件和 payload 只能是平凡类型靠static_assert在编译期把关带析构逻辑如持锁、持资源的对象进不了池。所有权是转移式的。引用计数要求最后一个消费者回收因此投递方在submit_*返回后不能再碰事件。多线程下这个契约靠纪律和注释维系出错就是 use-after-free没有运行时检查兜底。容量是编译期固定的。池耗尽时alloc返回nullptr队列满时入队返回false协调器按策略丢弃或拒绝。零拷贝换来了确定性但必须预先估计事件和队列容量估小了会丢事件估大了浪费 RAM。共享即耦合。多目标共享一个事件意味着消费者不能原地修改 payload否则影响其他消费者。需要每个消费者各有不同数据时就得回到复制零拷贝不适用。所以零拷贝适合的是事件 payload 相对小、共享只读、生命周期短、吞吐和延迟敏感的 MCU/SoC 场景。需要每个消费者独立数据、或 payload 含非平凡析构的场景复制反而是更简单正确的选择。coact 把零拷贝做成默认路径把选择权交给容量和类型约束完整实现与选择理由见 github.com/DeguiLiu/coact。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑