【C++高并发内存池篇·容器特化】自定义 STL allocator,把 `list`、`map` 装上内存池涡轮
真实项目中可以根据需要决定:只给 node 容器换 allocator,string 还是用默认的也完全 OK。的节点更复杂一些,但对 allocator 来说没区别,因为容器内部的 node 类型最后都会走你提供的。升级一个简单的线程安全壳子,用 mutex 就够了(先保证正确,再考虑极致性能)。但这已经是第三篇、第四篇的内容了 😄,这一篇先把**“怎么挂到 STL”**讲清楚。C++ 标准对
目录
上一篇我们写了一个定长内存池,用来给小对象提速。
这篇直接进阶:把定长内存池挂到 STL 容器上,用自定义allocator接管std::list、std::unordered_map等 node 容器的内存分配。
一、为什么要把内存池跟 STL 容器绑一起?
前一篇的池子更多是“手动调用”的风格:
Node* p = pool.create(...);
pool.destroy(p);
但实际工程里,大量内存开销都藏在:
std::list<T>std::forward_list<T>std::map / std::setstd::unordered_map / unordered_set
这些节点容器的节点 (node) 本质就是:
struct Node {
T value;
Node* next;
Node* prev;
// 可能还有一点管理字段
};
特点:
- 大量小对象,生命周期复杂、频繁插入删除;
- 默认都是
::operator new分配 node,抖动和碎片都不小; - C++ 标准本来就为此设计了
Allocator接口,允许你接管分配策略。
所以这篇的目标就一句话:
写一个
template<class T> class PoolAllocator,让std::list<T, PoolAllocator<T>>这类容器默认就走我们的定长内存池。
二、准备一个可复用的“节点级内存池”(线程安全版)
我们先给上一篇的 FixedSizePool 升级一个简单的线程安全壳子,用 mutex 就够了(先保证正确,再考虑极致性能)。
2.1 基础定长池(回顾简化版)
#include <cstddef>
#include <new>
#include <stdexcept>
class FixedSizePool {
public:
FixedSizePool(std::size_t blockSize, std::size_t blockCount)
: m_blockSize(adjustBlockSize(blockSize)),
m_blockCount(blockCount),
m_memory(nullptr),
m_freeList(nullptr)
{
if (m_blockSize == 0 || m_blockCount == 0) {
throw std::invalid_argument("blockSize and blockCount must be > 0");
}
initPool();
}
~FixedSizePool() {
::operator delete(m_memory, std::align_val_t(alignof(std::max_align_t)));
}
void* allocate() {
if (!m_freeList) {
throw std::bad_alloc{};
}
Node* node = m_freeList;
m_freeList = m_freeList->next;
return node;
}
void deallocate(void* ptr) {
if (!ptr) return;
Node* node = static_cast<Node*>(ptr);
node->next = m_freeList;
m_freeList = node;
}
std::size_t blockSize() const { return m_blockSize; }
private:
struct Node {
Node* next;
};
std::size_t m_blockSize;
std::size_t m_blockCount;
void* m_memory;
Node* m_freeList;
static std::size_t adjustBlockSize(std::size_t sz) {
if (sz < sizeof(Node)) sz = sizeof(Node);
constexpr std::size_t align = alignof(std::max_align_t);
if (sz % align != 0) {
sz += align - (sz % align);
}
return sz;
}
void initPool() {
std::size_t totalSize = m_blockSize * m_blockCount;
m_memory = ::operator new(totalSize, std::align_val_t(alignof(std::max_align_t)));
std::byte* p = static_cast<std::byte*>(m_memory);
m_freeList = nullptr;
for (std::size_t i = 0; i < m_blockCount; ++i) {
Node* node = reinterpret_cast<Node*>(p + i * m_blockSize);
node->next = m_freeList;
m_freeList = node;
}
}
};
2.2 简单加个全局线程安全封装
我们做一个“全局共享节点池管理器”,给 allocator 调用:
#include <mutex>
class GlobalNodePool {
public:
static GlobalNodePool& instance() {
static GlobalNodePool inst;
return inst;
}
void* allocate(std::size_t sz) {
// 为了简化:按 size 分类的那套先不做
// 假设所有节点都差不多大小,比如 64/80 字节范围
std::lock_guard<std::mutex> lock(m_mutex);
if (!m_pool || m_pool->blockSize() < sz) {
// 这里简单粗暴:重新建一个足够大的池
// 实际工程可以做 size-class 多池,这里以演示为主
m_pool.reset(new FixedSizePool(sz, m_defaultBlockCount));
}
return m_pool->allocate();
}
void deallocate(void* p) {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_pool) {
m_pool->deallocate(p);
} else {
// 理论上应该不会走到这里
::operator delete(p);
}
}
private:
GlobalNodePool()
: m_pool(nullptr),
m_defaultBlockCount(1024 * 16) // 默认 16K 块
{}
std::unique_ptr<FixedSizePool> m_pool;
std::mutex m_mutex;
std::size_t m_defaultBlockCount;
};
说明:
- 这里我们为了演示,把所有节点都塞进一个池(真实项目建议按 size 分 class);
allocate(sz)会在第一个请求时按 sz 造一个池,后面都复用。
三、实现最小可用版 STL Allocator:PoolAllocator<T>
C++ 标准对 allocator 的要求其实不少,但我们要“跑起来”只需要一版最小集合:
核心要素是:
template <class T>
class PoolAllocator {
public:
using value_type = T;
PoolAllocator() noexcept {}
template <class U>
PoolAllocator(const PoolAllocator<U>&) noexcept {}
T* allocate(std::size_t n);
void deallocate(T* p, std::size_t n) noexcept;
// 比较:所有 PoolAllocator<T> 视为等价(共享全局池)
bool operator==(const PoolAllocator&) const noexcept { return true; }
bool operator!=(const PoolAllocator&) const noexcept { return false; }
};
3.1 只针对“节点容器”:假定 n == 1 为主
std::list、std::map 这类 node 容器在大多数实现里,分配节点时 allocate(1);个别场景可能会一次性 allocate(n) 构造一串节点。
为了简单起步,我们这么处理:
- 如果
n == 1→ 用内存池; - 如果
n > 1→ 直接退回系统::operator new,不搞复杂。
代码:
#include <memory>
template <class T>
class PoolAllocator {
public:
using value_type = T;
PoolAllocator() noexcept = default;
template <class U>
PoolAllocator(const PoolAllocator<U>&) noexcept {}
T* allocate(std::size_t n) {
if (n == 1) {
void* mem = GlobalNodePool::instance().allocate(sizeof(T));
return static_cast<T*>(mem);
} else {
// 保底逻辑:大块直接走系统分配
return static_cast<T*>(::operator new(n * sizeof(T)));
}
}
void deallocate(T* p, std::size_t n) noexcept {
if (!p) return;
if (n == 1) {
GlobalNodePool::instance().deallocate(p);
} else {
::operator delete(p);
}
}
// 所有 PoolAllocator<T> 共享同一个全局池 → 认为相等
bool operator==(const PoolAllocator&) const noexcept { return true; }
bool operator!=(const PoolAllocator&) const noexcept { return false; }
};
四、实战:让 std::list 和 std::unordered_map 吃上内存池
4.1 给 std::list 换上 PoolAllocator
#include <list>
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
struct MyData {
int id;
double value;
// ... 业务...
};
using PoolList = std::list<MyData, PoolAllocator<MyData>>;
void worker_list(int threadId, int ops) {
PoolList lst;
for (int i = 0; i < ops; ++i) {
lst.push_back(MyData{ i, i * 0.1 });
if (lst.size() > 1000) {
lst.pop_front();
}
}
// scope 内析构,自动批量释放节点到内存池
}
int main() {
const int threadCount = 8;
const int perThreadOps = 100000;
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::thread> threads;
for (int i = 0; i < threadCount; ++i) {
threads.emplace_back(worker_list, i, perThreadOps);
}
for (auto& t : threads) t.join();
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end - start;
std::cout << "list with PoolAllocator finished in "
<< diff.count() << " s\n";
return 0;
}
你可以写两组对比:
- 组 A:
using DefaultList = std::list<MyData>; - 组 B:
using PoolList = std::list<MyData, PoolAllocator<MyData>>;
相同的压测逻辑跑一遍,看耗时 & 内存占用。
4.2 std::unordered_map 也可以同样接
unordered_map 的节点更复杂一些,但对 allocator 来说没区别,因为容器内部的 node 类型最后都会走你提供的 value_type 相关 allocator。
#include <unordered_map>
#include <string>
using PoolString = std::basic_string<char, std::char_traits<char>, PoolAllocator<char>>;
struct KeyHash {
using is_transparent = void;
std::size_t operator()(const PoolString& s) const noexcept {
return std::hash<std::string_view>{}(std::string_view(s.data(), s.size()));
}
};
using PoolMap = std::unordered_map<
PoolString,
int,
KeyHash,
std::equal_to<PoolString>,
PoolAllocator<std::pair<const PoolString, int>>
>;
void test_map() {
PoolMap mp;
for (int i = 0; i < 100000; ++i) {
PoolString key;
key.reserve(16);
key += 'k';
key += 'e';
key += 'y';
// 简化起见,这里直接构字符
mp.emplace(std::move(key), i);
}
}
这一块主要是演示:allocator 不只给容器节点用,还可以给 string / pair 用。
真实项目中可以根据需要决定:只给 node 容器换 allocator,string 还是用默认的也完全 OK。
五、要注意的细节 & 坑
5.1 这是“node 容器专用 allocator”
这一版设计是基于“单个节点分配”的前提来的:
n == 1→ 内存池;n > 1→ 系统new。
所以更适合:
std::liststd::forward_liststd::map / std::setstd::unordered_map / unordered_set
不适合直接拿去给 std::vector / std::string 做大块连续内存池(可以,但需要额外设计一个“变长块池”)。
5.2 线程安全 vs 性能
上面的 GlobalNodePool 用的是一个 mutex 包住 FixedSizePool:
- 优点:逻辑简单、安全;
- 缺点:极高并发场景下会有锁竞争。
后面如果你有兴趣,可以继续演进:
- 把池做到 thread-local 小池 + 中央池 的模式(类似 TCMalloc);
- 或者改用无锁栈(
std::atomic<Node*>+ CAS)做 free list。
但这已经是第三篇、第四篇的内容了 😄,这一篇先把**“怎么挂到 STL”**讲清楚。
5.3 池的生命周期和状态
我们这里用的是:
static GlobalNodePool& instance();
在普通业务进程里,这种“懒汉单例 + 静态局部变量”足够安全:
- 首次调用构造;
- 程序退出时由 runtime 统一析构。
如果你在 动态库 / 插件 中用 allocator,要注意:
- 确保 allocator / pool 的生命周期长于用它的容器;
- 可以考虑让池“永不析构”(泄一点内存换简单性,对常驻进程问题不大)。
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐

所有评论(0)