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 = 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 接口不变。

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

内存序: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)对下一个拿到该块的线程可见。

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 上有已知误报------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 循环,不改变信号量的唤醒扇出。

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


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

相关推荐
系统集成-鹰翔1 小时前
ERP推单数据库怎么备份:SQLite WAL模式下只复制db文件为什么可能漏任务
javascript·数据库·经验分享·后端
软件技术NINI2 小时前
基于SpringBoot和Vue的摇滚乐鉴赏网站
vue.js·spring boot·后端·spring·html·毕业设计·毕设
Tsuki_tl2 小时前
【总结】Redis五大数据类型应用场景及基本命令
数据库·redis·后端·缓存·缓存中间件·redis命令·redis 五大数据类型
后台模板学习2 小时前
Go Web开发中任务调度框架的源码解析与高可用实现方案
开发语言·后端·golang
系统集成-鹰翔2 小时前
ERP对接升级提示数据库迁移失败:SQLite逐版本事务到底回滚到哪一步
javascript·数据库·经验分享·后端
IT_陈寒2 小时前
深入理解SpringBoot:核心原理与最佳实践
前端·人工智能·后端
学长毕设2 小时前
基于SpringBoot的校园礼服租赁系统的设计与实现
java·spring boot·后端
Best_Liu~3 小时前
SpringBoot全局日期格式化
java·spring boot·后端
小蒜学长3 小时前
基于Django+Vue喵汪联盟宠物领养系统的设计与实现(代码+数据库+LW)
后端·python·django·宠物领养系统·流程规范化
Dwzun3 小时前
基于SpringBoot3+Vue3的油莎豆种植管理系统
java·前端·vue.js·spring boot·后端·python·计算机网络