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