不太懂 atomic?我花了一下午从 freelist 写到 CAS 无锁栈

从零手写 C++ 内存池 ------ 从 freelist 到无锁并发

一个下午,10 个功能,从固定大小分配器到 CAS 无锁栈。这篇文章记录每一层的设计思路和核心代码。适合想理解"malloc 内部怎么工作"的 C++ 初学者。


为什么要手写内存池

malloc/free 是通用目的的内存分配器------它要处理各种大小、多线程竞争、碎片回收,内部逻辑极其复杂。但很多场景不需要通用的 malloc------你的 muduo 在处理几十万个 TCP 连接时,每个连接的 Buffer 大小固定。每来一个新连接都要 new Buffer(4096),频繁的系统调用拖死性能。

内存池做一件事:预分配一大块内存,自己管理分配和回收,系统调用减少 90% 以上。

这篇文章从最简单的 freelist 开始,一步步加上多尺寸管理、线程本地缓存、全局调剂、CAS 无锁、内存对齐、越界检测。代码在 GitHub 上,MIT 协议:

memory-poolhttps://github.com/ch0sen1pm/memory-pool


第 1 层:FixedAllocator --- freelist 复用

核心思路: 预分配一块连续内存,切成等大块。空闲块的前 8 字节存指针指向下一个空闲块------这就是 freelist。分配/回收都是 O(1)。

ini 复制代码
预分配内存: [block][block][block][block]...
freelist:   head → block3 → block2 → block1 → block0 → nullptr

为什么 freelist 没有额外内存开销? 空闲块的前 8 字节正好存 next 指针。分配出去后用户数据覆盖这块内存------不需要链表指针了。回收时重新写回 next。复用同一块内存存指针,零额外开销。

cpp 复制代码
template <size_t BlockSize, size_t Align, bool Debug>
class FixedAllocator {
    struct Block { Block* next; };   // 只用 8 字节存链表

    Block* freeList_;                 // 空闲块链表头
    char* pool_;                      // 预分配的大块内存

    void* allocate() {
        if (!freeList_) return nullptr;
        Block* b = freeList_;
        freeList_ = b->next;          // head 后移
        return b;
    }

    void deallocate(void* p) {
        Block* b = static_cast<Block*>(p);
        b->next = freeList_;          // 归还的块指向原来的 head
        freeList_ = b;                // 自己成为新 head
    }
};

构造时建 freelist: new char[BlockSize * numBlocks] → 切成等大块 → 每块 b->next = freeList_; freeList_ = b → 插到头。栈式释放------LIFO,最后归还的块被最先取走。


第 2 层:SlabAllocator --- 多尺寸管理

FixedAllocator 只管理一种大小。实际需要支持多种大小------8 字节、16 字节、32 字节、64 字节... SlabAllocator 管理多个 FixedAllocator,根据请求大小分到合适的 slab:

cpp 复制代码
class SlabAllocator {
    FixedAllocator<8>   slab8;
    FixedAllocator<16>  slab16;
    FixedAllocator<32>  slab32;
    FixedAllocator<64>  slab64;
    // ...

    void* allocate(size_t size) {
        size_t rounded = roundUp(size);    // 向上取整到 2 的幂次
        if (rounded <= 8)   return slab8.allocate();
        if (rounded <= 16)  return slab16.allocate();
        // ...
        return ::malloc(size);             // 超大 → 直接 malloc
    }
};

roundUp 位运算: n--; n |= n>>1; ...; return n+1 ------最高位的 1 扩散到所有低位,+1 得 2 的幂次。比如 30 → 32,60 → 64。经典算法。


第 3 层:ThreadCache --- thread_local 无锁

多线程下多个线程共用一个 FixedAllocator 需要加锁------每个 allocate/deallocate 都要争锁。但如果每个线程有自己独立的 freelist,就不需要锁了。

cpp 复制代码
template <size_t BlockSize>
class ThreadCache {
    static thread_local FixedAllocator<BlockSize> alloc_;

    void* allocate() { return alloc_.allocate(); }     // 无锁!
    void deallocate(void* p) { alloc_.deallocate(p); }
};

thread_local 关键字做了什么: 每个线程有自己独立的 alloc_ 副本------物理上是两块不同的内存。线程 A 跟线程 B 同时调 allocate(),各自操作自己的 freelist,互不干扰。线程安全不是"加了锁"------是"根本没有共享数据"。

static + thread_local 组合:static = 所有 ThreadCache 对象共享一份 alloc_,thread_local = 但不同线程的副本独立。两个关键字各管各的。


第 4 层:CentralCache --- 全局调剂

ThreadCache 容量有限(默认 1024 块)。用完了返回 nullptr------需要从全局池补货。CentralCache 是线程间的"块交换中心":

css 复制代码
线程 A: allocate() → ThreadCache 空了 → 找 CentralCache 批量要
线程 B: deallocate() → ThreadCache 太多了 → 批量还给 CentralCache

CentralCache: 持有 FixedAllocator + mutex。只在批量转移时加锁
cpp 复制代码
template <size_t BlockSize>
class CentralCache {
    FixedAllocator<BlockSize> alloc_;
    std::mutex mutex_;

    void* allocate() {
        std::lock_guard<std::mutex> lock(mutex_);
        return alloc_.allocate();
    }
};

ThreadCache 99% 的请求不碰锁------只在补货/回收时锁一次。跟 jemalloc 的 tcache + ecache 两层缓存一个道理。


第 5 层:LockFreeStack --- CAS 无锁 freelist

CentralCache 的互斥锁在竞争激烈时成为瓶颈。CAS(Compare And Swap)原子指令可以替代锁------多线程并发 push/pop 不需要任何锁。

CAS 原理: "如果这块内存还是原来的值,就把新值写进去;如果被别的线程改了,重试。"

cpp 复制代码
void push(Node* node) {
    node->next = head_.load();        // 新节点指向当前头
    while (!head_.compare_exchange_weak(
               node->next, node)) {   // CAS: 如果 head 没变 → 改成 node
        // CAS 失败 → 其他线程抢先了 → 重试
    }
}

Node* pop() {
    Node* node = head_.load();        // 记住当前头
    while (node && !head_.compare_exchange_weak(
                      node, node->next)) {
        // CAS 失败 → 重试
    }
    return node;
}

为什么不会死锁: CAS 失败不阻塞------原地重试。其他线程的修改帮你的链表维护好了,你重读一次新的头就行。"合作式无锁"------每个线程的修改对其他线程有利。

内存序: push 用 release------保证写入后对其他线程可见。pop 用 acquire------保证读到前一个 push 的完整数据。失败都用 relaxed------反正是重试,无所谓。这是 CPU 的内存模型------先记住固定搭配,以后看 OSTEP 理解。


第 6 层:内存对齐

SIMD 指令(AVX/SSE)要求数据地址是 16/32/64 的倍数。alignas 关键字让编译器保证对齐:

cpp 复制代码
struct alignas(64) Block {
    Block* next;
};

// 每个块的起始地址必须是 64 的倍数
// stride = (BlockSize + Align - 1) & ~(Align - 1)  --- 向上取整 BlockSize 到 Align 倍数

(addr + Align - 1) & ~(Align - 1) 向上取整地址到 Align 的倍数。比如 addr=0x1003, Align=64:(0x1003+63) & ~63 = 0x1040 ✅。位运算比 ceil(a/64)*64 快。


第 7 层:Debug 模式 --- guard bytes 检测越界

每个块前后放哨兵字节(0xDEADBEEFCAFEBABE)。deallocate 时检查哨兵是否被改------被改了说明有越界写。

less 复制代码
分配: [next 8B] [front guard 8B] [用户数据 BlockSize] [back guard 8B]
        ↑         ↑               ↑                    ↑
        b        b+8            b+16               b+stride-8
                      allocate 返回 b+16
cpp 复制代码
void deallocate(void* p) {
    Block* b = reinterpret_cast<Block*>(reinterpret_cast<char*>(p) - 16);
    uint64_t* front = reinterpret_cast<uint64_t*>(reinterpret_cast<char*>(b) + 8);
    assert(*front == GUARD && "front guard corrupted!");
    // 检查 back guard...
}

if constexpr (Debug) 决定是否编译 guard 代码。Debug=false 时全部跳过------零运行时开销。


性能数据

多线程 4 核,100K 次 alloc+free per thread:

bash 复制代码
FixedAllocator + mutex:  18M ops/s
malloc           + mutex: 13M ops/s
ThreadCache      no lock: 33M ops/s  --- 2.5x faster than malloc

单线程 SlabAllocator vs malloc 基本持平------微基准的差异在毫秒级。多线程场景 thread_local 无锁的优势明显。


项目结构

arduino 复制代码
memory-pool/
├── fixed_allocator.h    --- freelist 单链表 + 对齐 + Debug guard
├── slab_allocator.h     --- 多尺寸管理 + roundUp 位运算
├── thread_cache.h       --- thread_local 无锁分配
├── central_cache.h      --- mutex 全局调剂
├── lockfree_stack.h     --- CAS 原子无锁栈
├── bench.cpp            --- 单线程 benchmark
├── bench_mt.cpp         --- 多线程 benchmark
├── main.cpp             --- 综合测试
└── README.md

6 个头文件,2000+ 行代码,零外部依赖。


更新记录

  • 7/19 FixedAllocator --- freelist 固定大小分配器
  • 7/19 SlabAllocator --- 多尺寸 + roundUp
  • 7/19 Benchmark vs malloc(单线程)
  • 7/19 ThreadCache --- thread_local 无锁
  • 7/19 CentralCache --- mutex 全局池
  • 7/19 LockFreeStack --- CAS 原子无锁栈
  • 7/19 内存对齐 --- alignas + stride 向上取整
  • 7/19 Debug 模式 --- guard bytes 越界检测
  • 7/19 Benchmark vs malloc(多线程)--- ThreadCache 2.5x 更快

项目已完结。一个下午从 freelist 到 CAS 无锁。代码在 GitHub 上,MIT 协议,欢迎 star ⭐

memory-poolhttps://github.com/ch0sen1pm/memory-pool 配套项目:my_muduo (Reactor 网络库)+ my_logger(日志库)

相关推荐
红豆诗人19 小时前
C++ string 和 vector 基础:从常用接口到模拟实现
c++·stl
良木生香19 小时前
【C++初阶】STL—— Stack & Queue 从入门到精通:容器适配器、迭代器与经典面试题
java·开发语言·c++·算法·zookeeper
小小晓.19 小时前
C++小白记:vector
开发语言·c++·算法
脱胎换骨-军哥20 小时前
C++ 嵌入式编程实例:从寄存器操作到底层驱动开发
开发语言·c++·驱动开发
鱼子星_20 小时前
【C++】vector
开发语言·c++·笔记·stl
吃着火锅x唱着歌20 小时前
Effective C++ 学习笔记 条款38 通过复合塑模出has-a或“根据某物实现出”
c++·笔记·学习
syagain_zsx20 小时前
库制作与原理 · 链接知识笔记
c语言·c++·笔记·动态库·静态库
qz5zwangzihan120 小时前
题解:Atcoder Beginner Contest abc467 A~D
c++·题解·atcoder·abc467
jinyishu_1 天前
模拟实现 C++ 栈和队列——从适配器模式看懂 STL 容器之美
java·c++·适配器模式