ARM-Linux 嵌入式库:内存池与信号量的无锁化改造
newosp 是纯头文件的 C++17 嵌入式库,双平台:POSIX(Linux/macOS)与 RT-Thread。项目硬约束有三条:热路径禁止堆分配、-fno-exceptions -fno-rtti 兼容、共享数据必须线程安全。这次重构把 FixedPool 从互斥锁换成无锁空闲链表,LightSemaphore 重写了平台的并发原语选择,两处都落在热路径上。
FixedPool:一个 32 位原子装下整条空闲链表
旧实现用 osp::Mutex 保护空闲链表,每次 Allocate / Free 都要进出临界区。新实现把互斥锁换成单字(single word)的 CAS 空闲链表,也就是 Treiber stack。
链表头压进一个 32 位原子:低 16 位存空闲块索引,高 16 位存 ABA 标签(FreePoolPackHead)。能压进 32 位,是因为池子容量本身是 16 位可表达的量(kFreeIndexMask = 0xFFFF,static_assert(MaxBlocks <= 0xFFFF)),空闲链表只在一段连续的 block 数组里穿行,索引足够表达。单字 CAS 意味着在 x86 和 ARM Cortex-M(LDREX/STREX)上都是原生指令,不用链接 libatomic,32 位目标上也没有撕裂问题。
ABA 用标签位解决:每次 pop 成功就推进标签,这样即使链表头被 push/pop 回绕回同一个索引,标签不同,CAS 也判不等。代价是容量上限砍到 16 位,对嵌入式池子来说够用。
两层语义是分开的:FixedPool 只做裸块分配,ObjectPool 在其上叠加 placement new 做类型化分配。这次只动了 FixedPool 的并发原语,ObjectPool 接口不变。
内存序:CAS 为什么用 acq_rel,next 字段为什么豁免插桩
第一版无锁栈漏了两处。
一是 Treiber 栈的 next 字段本来就是良性竞争:Allocate 里 LoadIndex(idx) 读到的 next 可能已被别的线程写掉,但 CAS 失败会把这条过期读数丢进 CAS 重试,不构成数据竞争方向上的错误。然而 TSan 会在这个跨线程读 next 处报误报。这里用 LoadIndex / StoreIndex 两个函数加了 __attribute__((no_sanitize("thread")))(宏 OSP_TSAN_NO_RACE),让工具跳过这两个点的插桩,避免把良性的 next 竞争误判成缺陷。
二是空闲链表头的 CAS 用了 memory_order_acq_rel。这个需求不是来自"链表头更新"本身,而是来自对象生命周期的送达 :一个线程 Free 之后 StoreIndex 写入块的内存内容,另一个线程 Allocate 拿到同一块 LoadIndex 读到它。如果按 CAS 依赖惯用 relaxed,StoreIndex 的写不会被块释放方与重分配方的 happens-before 关系覆盖,理论上在下一次 CAS 之前是弱序的。acq_rel 让 CAS 前后的 release/acquire 配对,块内容的写入(StoreIndex)对下一个拿到该块的线程可见。
注意 LoadIndex/StoreIndex 的注解是"绕过插桩",不是"绕过正确性"------顺序性由 AcqRel 的 head CAS 提供,注解只是不让 TSan 把受 head-RMW 保护的良性竞争当缺陷。
LightSemaphore:并发原语按平台分派
旧实现是 mutex + condition_variable 计数信号量,有两个问题。一是 condvar 的等待路径在嵌入式上并不便宜,二是 GCC 的 libtsan 在 condvar 上有已知误报------Wait 里 wait 返回后读取计数,会被 TSan 判成数据竞争,实际是良性的。
新实现保留了计数缓冲(count_,给 TryWait/Available 用),但并发原语按平台分派:
- POSIX(Linux/macOS) :用
sem_t。构造时sem_init,失败则退化为纯计数模式(valid_ = false)。Signal()是count_.fetch_add(release)+sem_post;Wait()/WaitFor()是sem_wait/sem_timedwait。POSIX 语义天然带开/关门(notify 与 wait 间无丢失窗口)。 - 非 POSIX(RT-Thread) :退化为
count_+condition_variable。这里Signal做count_.fetch_add后cv_.notify_one,Wait在count_ == 0时cv_.wait。
cpp
void Signal() noexcept {
count_.fetch_add(1U, std::memory_order_relaxed);
#if defined(OSP_PLATFORM_LINUX) || defined(OSP_PLATFORM_MACOS)
if (valid_) {
::sem_post(&sem_);
}
#else
cv_.notify_one();
#endif
}
SignalN(n) 也不是"合并成一次唤醒",而是简单循环调用 Signal() 的批量别名 ------对 POSIX 路径等价于 N 次 sem_post(N 个阻塞线程可各自放行,保留计数式信号量语义),对 fallback 路径等价于 N 次 fetch_add + 可能多次 notify。它省的是调用方手写的 for 循环,不改变信号量的唤醒扇出。
TSan 清净:把无锁代码洗到工具无话可说
除了上文两处(LoadIndex/StoreIndex 豁免 + head CAS 用 acq_rel),这轮改动还按 .clang-format(21.1.8)把 mem_pool.hpp 整体重排,并修正了过时的文档注释。目标不是"消灭竞争"(next 的良性竞争消灭不了),而是"让 TSan 只报告真实缺陷"。
验证
- host + 并发压测通过;
FixedPool精确统计 Used/Free 计数。 - 全量单测在 ASan / UBSan / TSan 下通过,TSan 用 clang(权威门禁)验证无数据竞争。
- 版本 0.6.1 → 0.6.2。
改动集中在 include/osp/mem_pool.hpp 和 include/osp/semaphore.hpp,接口层没有破坏性变更。一次热路径分配从"进出临界区"变成"一次原生 CAS"。
本文对应的代码改动见 newosp 仓库 include/osp/mem_pool.hpp、include/osp/semaphore.hpp。