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 做两件事:把 last 的 next 指向当前 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。


相关推荐
徐小黑ACG5 分钟前
Golang 基础06 接口interface
开发语言·后端·golang
三小河21 分钟前
从 Markdown 到 Generative UI:AI 如何从“生成答案”进化到“生成界面”?
前端·人工智能·后端
韩振方30 分钟前
为什么加了 sudo,写配置文件还是 Permission denied?
后端
牛奔1 小时前
mise 管理多版本 Go 项目
开发语言·前端·chrome·后端·golang
打工仔折腾 AI2 小时前
FaceFusion本地换脸实战:Windows整合包、模型选择与遮罩调参记录
人工智能·windows·后端·python·深度学习·性能优化·ai agent 实战
Nebula_g2 小时前
JavaSE拓展:工具类Executors
java·开发语言·后端·spring·基础·javase
弈栈录2 小时前
Java 后端高并发设计:线程池、限流、熔断与降级
后端·架构
邱杰4702 小时前
从零手写数智人事系统(十二):部门树、角色授权与菜单维护
后端
PC2005_cloud2 小时前
AI 汉字漂移实录:它为什么总把「学习」写成日语「学習」
前端·后端
码剑客2 小时前
告别逐张排查DICOM:6维度本地批量校验方案,数据零上传更合规
后端