ReclaimBatcher 批量回收:RT-Thread 单核与 Linux SMP 通用

ReclaimBatcher 批量回收:RT-Thread 单核与 Linux SMP 通用

项目链接:github.com/DeguiLiu/co...

Dispatcher 每处理完一个事件就调 event_gc(),ref_ctr 归零时把块归还池。一次批量取出 8 个事件、8 次独立 reclaim(),就是 8 次 CAS 竞争同一个 free_head 原子------每次 CAS 独占 cache line 的 exclusive 所有权,高吞吐下放大 ping-pong。

sequenceDiagram participant D as Dispatcher participant FH as free_head (cache line) D->>FH: reclaim(e1) CAS D->>FH: reclaim(e2) CAS D->>FH: reclaim(e3) CAS D->>FH: reclaim(e4) CAS Note over FH: 4 次独占 RMW,每次<br/>使其他核心的副本失效

暂存 + 一次 splice

ReclaimBatcher 在 Dispatcher 的 batch 生命周期内维护一个 per-pool 的待回收链。事件不立即执行 head CAS 回池,而是链入临时链表 (prepend,新块成为链头);batch 结束时,或链长达到 kReclaimBatchCap = 16,调 pool_reclaim_chain 把整条链一次性 splice 回 free-list------一次 CAS 落 N 个块

flowchart LR subgraph Before[&#34;逐事件回收(N 次 CAS)&#34;] E1[&#34;e1 → CAS&#34;] --> E2[&#34;e2 → CAS&#34;] E2 --> E3[&#34;e3 → CAS&#34;] E3 --> EN[&#34;eN → CAS&#34;] end subgraph After[&#34;批量回收(1 次 splice)&#34;] B1[&#34;e1 → 链入 pending&#34;] --> B2[&#34;e2 → 链入 pending&#34;] B2 --> B3[&#34;e3 → 链入 pending&#34;] B3 --> BN[&#34;eN → 链入 pending&#34;] BN --> SPLICE[&#34;pool_reclaim_chain<br/>一次 CAS splice&#34;] end

链入 pending 的是块的 next 字段写,不是 head CAS------写普通内存(块自己的首 4 字节),不碰共享原子 head,所以暂存阶段没有 cache-line 争用。真正的成本集中在收尾那一次 CAS。

splice 原语

pool_reclaim_chain 接口是 (rec, first, last, count)。调用方已把链串好:链头 first、链尾 last(块通过各自 next 字段相连)。splice 做两件事:把 lastnext 指向当前 free_head,然后一次 CAS 把 free_head 换成 first

cpp 复制代码
inline void pool_reclaim_chain(PoolRecord* rec, uint32_t first,
                               uint32_t last, uint16_t count) noexcept
{
    const CriticalSection::Token tok = rec->cs.save(rec->cs.ctx);
    uint32_t head = rec->free_head.load(std::memory_order_relaxed);
    unsigned spin = 0U;
    for (;;) {
        /* `last` 的 next 指向当前 free_head, 把整条旧链头接上去 */
        const uintptr_t last_addr = pool_block_base(rec->base, last, rec->block_size);
        pool_store_next(last_addr, pool_head_index(head));
        uint32_t new_head = pool_pack_head(first, pool_head_tag(head) + 1U);
        if (rec->free_head.compare_exchange_weak(
                head, new_head,
                std::memory_order_relaxed, std::memory_order_relaxed)) {
            break;
        }
        pool_backoff(head, rec->free_head, spin);
    }
    rec->used.fetch_sub(count, std::memory_order_relaxed);
    rec->cs.restore(rec->cs.ctx, tok);
}

count 只用于 used 计数聚合(一次 fetch_sub(count) 替代 count 次递减),链表本身不靠 count 遍历。

注意:pending 链是 prepend,splice 时 last 的 next 才指向旧 head

release() 里链入是前插

cpp 复制代码
Pending* p = pending_of(rec);
if (p->count == 0U) {
    p->first = idx;
    p->last = idx;
} else {
    detail::pool_store_next(addr, p->first);  // 新块 next -> 旧链头
    p->first = idx;                            // 新块成为链头
}
p->count = p->count + 1U;

所以 first 始终是"最新的一块",last 是"最早的一块"。这跟 Treiber 栈的 push 方向一致------splice 时 last 的 next 指向当前 free_head,把整条子串接在旧链头之前,一次 CAS 换头。顺序无关紧要(free list 无序),这么做只为复用现有块 next 字段、不额外分配链表节点。

为什么"写 last 的 next"和"head CAS"必须在同一临界区内

这是 SMP 正确性的关键。splice 先写 last 块的 next(普通内存),再做 head CAS(原子)。在单核 RT-Thread 上,irq mask 整个 pool_reclaim_chain 都在一个临界区内,alloc 和 reclaim 不可能并发,这个 next 写绝不会和别的线程撞上。

但 SMP host 上(ReclaimBatcher 的 CS 被换成 SpinCriticalSection),alloc 线程的 load_next(idx) 可能与 reclaim 线程的 pool_store_next(last_addr, ...) 并发读写同一块的 next

sequenceDiagram participant A as Alloc 线程 participant R as Reclaim 线程 participant B as 块 X 的 next 字段 R->>B: pool_store_next(X, head_idx) -- 写 A->>B: load_next(X) -- 并发读 Note over B: X 同时出现在 free 链和 alloc 链

如果 next 写在临界区之外、head CAS 才在区里,那么 alloc 线程可能在 free head 尚未指向这块时就已经读到它的 next(这是它的旧 free-list 值),随后 splice 把 head 换过来,alloc 又通过新 head 拿到它------但两块链可能错位。把 next 写和 head CAS 放在同一个 CS 内,alloc/reclaim/batch-splice 对同一块的 next 访问互斥,杜绝这种错位。

cpp 复制代码
inline CriticalSection make_spin_critical_section(SpinCriticalSection& scs) noexcept
{
    CriticalSection cs;
    cs.save = [](void* ctx) -> Token {
        auto* s = static_cast<SpinCriticalSection*>(ctx);
        while (s->flag.test_and_set(std::memory_order_acquire)) { }
        return 1U;
    };
    cs.restore = [](void* ctx, Token) {
        auto* s = static_cast<SpinCriticalSection*>(ctx);
        s->flag.clear(std::memory_order_release);
    };
    return cs;
}

单核 RT-Thread 保持 irq mask CS(O(1)),不受此问题影响。

触发边界

release() 在链长达到 kReclaimBatchCap(=16)时立即 splice_locked,否则留在 pending 里等 flush()(batch 结束时统一 splice)。这保证两个极端都不恶化:

  • 吞吐极高、batch 极长时,链不会无限长------每 16 块就 splice 一次,均摊每块成本还是"1/16 次 head CAS"。
  • batch 短时,等 flush 一次收尾,避免为 1-2 块也 splice。

每个 batch 内最多跟踪 kMaxPending = 4 个不同 pool 的 pending 链(active_count_ 上限),超出是配置错误(COACT_ASSERT)。

微基准

4 生产者 + 1 回收线程,批量回收约 2.4x 提升。瓶颈从 free-head 的 exclusive CAS 争用转移到 per-pool 链的本地写操作;单核上几乎零成本,SMP 上单次 splice 覆盖 N 个块,均摊低于 N 次 CAS。


相关推荐
攻城有术1 小时前
专项攻克-springcloud及其组件
后端·spring·spring cloud
Kripath_Rion3 小时前
带你速通计算机经典论文(一):分布式系统篇
分布式·后端·架构
似璟如你4 小时前
Java 开发者的 Go 语法基础:从 0 开始快速上手 Go
java·开发语言·后端·golang·go·编程语言
Codelinghu5 小时前
AI 写代码越强,程序员越不能只懂代码
后端
卡卡敲码5 小时前
Skills 撞车了,Agent 怎么选
后端
羑悻5 小时前
周一早上三件事砸过来,我以为要延期,结果 AI 替我扛了一半!
后端
文艺理科生5 小时前
3 年,8 种方案,1 次重构:LangChain 记忆方案如何从混乱走向清晰
前端·后端·架构
Ai拆代码的曹操6 小时前
Agent 做错了怎么办?Self-Critique 机制拆解
后端·agent·ai编程