ARM-Linux 嵌入式库:内存池与信号量的无锁化改造

ARM-Linux 嵌入式库:内存池与信号量的无锁化改造

newosp 是纯头文件的 C++17 嵌入式库,双平台:POSIX(Linux/macOS)与 RT-Thread。项目硬约束有三条:热路径禁止堆分配、-fno-exceptions -fno-rtti 兼容、共享数据必须线程安全。这次重构把 FixedPool 从互斥锁换成无锁空闲链表,LightSemaphore 重写了平台的并发原语选择,两处都落在热路径上。

flowchart LR subgraph 旧FixedPool A[Allocate] --> B[osp::Mutex.lock] B --> C[pop free list] C --> D[osp::Mutex.unlock] end subgraph 新FixedPool E[Allocate] --> F[CAS head: index+tag] F --> G{失败?} G -->|重试| F G -->|成功| H[返回 block] end

FixedPool:一个 32 位原子装下整条空闲链表

旧实现用 osp::Mutex 保护空闲链表,每次 Allocate / Free 都要进出临界区。新实现把互斥锁换成单字(single word)的 CAS 空闲链表,也就是 Treiber stack。

链表头压进一个 32 位原子:低 16 位存空闲块索引,高 16 位存 ABA 标签(FreePoolPackHead)。能压进 32 位,是因为池子容量本身是 16 位可表达的量(kFreeIndexMask = 0xFFFFstatic_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 接口不变。

packet-beta 0-15: &#34;Free index (16-bit)&#34; 16-31: &#34;ABA tag (16-bit)&#34;

内存序:CAS 为什么用 acq_rel,next 字段为什么豁免插桩

第一版无锁栈漏了两处。

一是 Treiber 栈的 next 字段本来就是良性竞争:AllocateLoadIndex(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 依赖惯用 relaxedStoreIndex 的写不会被块释放方与重分配方的 happens-before 关系覆盖,理论上在下一次 CAS 之前是弱序的。acq_rel 让 CAS 前后的 release/acquire 配对,块内容的写入(StoreIndex)对下一个拿到该块的线程可见。

sequenceDiagram participant T1 as 线程A(Free) participant H as free_head(CAS) participant T2 as 线程B(Allocate) Note over T1: StoreIndex: 写块首4字节 T1->>H: CAS push, acq_rel Note over H: release: 块写入在 head 更新前可见 T2->>H: CAS pop, acq_rel Note over H: acquire: 读到 head 即看到 A 的写入 T2->>T2: LoadIndex: 读到有效 next

注意 LoadIndex/StoreIndex 的注解是"绕过插桩",不是"绕过正确性"------顺序性由 AcqRel 的 head CAS 提供,注解只是不让 TSan 把受 head-RMW 保护的良性竞争当缺陷。

LightSemaphore:并发原语按平台分派

旧实现是 mutex + condition_variable 计数信号量,有两个问题。一是 condvar 的等待路径在嵌入式上并不便宜,二是 GCC 的 libtsan 在 condvar 上有已知误报------Waitwait 返回后读取计数,会被 TSan 判成数据竞争,实际是良性的。

新实现保留了计数缓冲(count_,给 TryWait/Available 用),但并发原语按平台分派

  • POSIX(Linux/macOS) :用 sem_t。构造时 sem_init,失败则退化为纯计数模式(valid_ = false)。Signal()count_.fetch_add(release) + sem_postWait()/WaitFor()sem_wait/sem_timedwait。POSIX 语义天然带开/关门(notify 与 wait 间无丢失窗口)。
  • 非 POSIX(RT-Thread) :退化为 count_ + condition_variable。这里 Signalcount_.fetch_addcv_.notify_oneWaitcount_ == 0cv_.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 循环,不改变信号量的唤醒扇出。

flowchart LR subgraph POSIX 路径 T1[Signal] --> U1[sem_post] V1[SignalN n] --> W1[n 次 sem_post] X1[Wait] --> Y1[sem_wait] X2[WaitFor t] --> Z1[sem_timedwait] end subgraph RT-Thread 路径 U2[Signal] --> F2[count_.fetch_add] F2 --> N2[cv_.notify_one] W2[Wait] --> C2{count_==0?} C2 -->|yes| CV2[cv_.wait] C2 -->|no| D2[count_--] end

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.hppinclude/osp/semaphore.hpp,接口层没有破坏性变更。一次热路径分配从"进出临界区"变成"一次原生 CAS"。


本文对应的代码改动见 newosp 仓库 include/osp/mem_pool.hppinclude/osp/semaphore.hpp

相关推荐
SamDeepThinking1 小时前
写代码,到底应该参考优秀作品,还是坚持自己的想法?
后端
cfm_29141 小时前
基于OAuth2.0实现微服务SSO单点登录
后端·spring·微服务·架构
橘色的喵1 小时前
MergeCell 合并机制:双平台事件流去重
后端
予怀9601 小时前
ClickHouse踩坑:一个sum()引发的"数字不一致"悬案
后端
橘色的喵1 小时前
ReclaimBatcher 批量回收:RT-Thread 单核与 Linux SMP 通用
后端
攻城有术2 小时前
专项攻克-springcloud及其组件
后端·spring·spring cloud
Kripath_Rion3 小时前
带你速通计算机经典论文(一):分布式系统篇
分布式·后端·架构