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

1 阅读4分钟

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。
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。