在传统C++开发中,我们习惯使用new/delete或malloc/free进行动态内存分配。但在高性能场景下,这种常规方式存在三个致命缺陷:
以Unreal Engine为例,其每帧需要处理:
这个分配器的核心逻辑简单到令人发指:
cpp复制void* do_allocate(size_t bytes, size_t alignment) {
// 指针加法就是全部魔法
void* p = std::align(alignment, bytes, current_buffer, space_remaining);
if (p) {
current_buffer = static_cast<char*>(p) + bytes;
space_remaining -= bytes;
return p;
}
return allocate_new_buffer(bytes, alignment); // 缓冲区不足时申请新内存
}
void do_deallocate(void*, size_t, size_t) {
// 释放操作是空实现!
}
在libstdc++的实现中,以下几个设计保证了极致性能:
单指针偏移分配:相比malloc的复杂内存查找,这里只是做指针加法
几何增长策略:当缓冲区耗尽时,按1.5倍大小申请新内存块
cpp复制size_t new_size = std::max(next_buffer_size, bytes);
new_size = (new_size * 3 + 1) / 2; // 1.5倍增长
侵入式链表管理:每个内存块尾部嵌入链表节点,实现O(1)串联
cpp复制struct buffer_node {
buffer_node* next;
size_t size;
};
这个设计最反直觉的地方在于:它根本不释放单个对象。所有内存都在以下两种时机批量释放:
release()方法这种"分配如山倒,释放如抽丝"的策略带来了两个优势:
假设我们依次分配三个对象:
code复制[块头][对象A][对象B][对象C][链表节点]
当对象B被"释放"时,内存实际没有任何变化。直到整个缓冲区释放,所有内存才一次性归还系统。
Google Protocol Buffers的Arena分配器采用相同理念:
protobuf复制message MyMessage {
option (arena_alloc) = true; // 启用Arena分配
}
关键相似点:
测试场景:连续分配100万个32字节对象
| 分配器类型 | 总耗时(ms) | 单次分配延迟(ns) |
|---|---|---|
| malloc/free | 48.2 | 48.2 |
| monotonic_buffer | 2.1 | 2.1 |
| Protobuf Arena | 1.8 | 1.8 |
帧级内存管理(游戏引擎)
cpp复制void game_loop() {
pmr::monotonic_buffer_resource frame_allocator;
pmr::vector<Entity> entities(&frame_allocator);
// 每帧开始时自动重置
frame_allocator.release();
}
交易订单处理(高频交易)
cpp复制void process_order(Order* order) {
static thread_local pmr::monotonic_buffer_resource tls_allocator;
pmr::vector<Execution> executions(&tls_allocator);
// 订单处理完成后自动释放
}
协议解析临时对象(网络通信)
cpp复制Message parse_message(NetworkPacket packet) {
pmr::monotonic_buffer_resource parse_allocator;
pmr::vector<Token> tokens(&parse_allocator);
// 解析完成后message深拷贝出来
return Message{tokens};
}
libstdc++中的对齐实现堪称教科书:
cpp复制void* align(size_t alignment, size_t size,
void*& ptr, size_t& space) {
auto pn = reinterpret_cast<uintptr_t>(ptr);
auto aligned = (pn + alignment - 1) & -alignment;
auto new_space = space - (aligned - pn);
if (new_space < size) return nullptr;
ptr = reinterpret_cast<void*>(aligned);
space = new_space;
return ptr;
}
这个算法精妙之处在于:
-alignment利用了补码表示即使分配失败,也能保证不内存泄漏:
cpp复制void* allocate_new_buffer(size_t bytes, size_t alignment) {
size_t new_size = calculate_new_size(bytes);
void* new_buffer = upstream_alloc(new_size);
if (!new_buffer) {
if (auto mem = try_allocate_from_existing(bytes, alignment))
return mem;
throw std::bad_alloc();
}
link_new_buffer(new_buffer, new_size);
return do_allocate(bytes, alignment); // 重试分配
}
对于多线程场景,应该使用thread_local修饰:
cpp复制thread_local pmr::monotonic_buffer_resource tls_allocator;
void worker_thread() {
pmr::vector<int> vec(&tls_allocator);
// 每个线程有独立分配器
}
合理设置初始缓冲区大小可避免多次分配:
cpp复制char initial_buffer[1MB];
pmr::monotonic_buffer_resource alloc{
initial_buffer, sizeof(initial_buffer)};
所有STL容器都支持PMR分配器:
cpp复制pmr::vector<std::pmr::string> strings(&allocator);
pmr::unordered_map<int, pmr::string> map(&allocator);
症状:抛出std::bad_alloc
解决方案:
检查要点:
当遇到不兼容PMR的库时:
cpp复制void legacy_api(const std::vector<int>&);
void wrapper() {
pmr::vector<int> tmp(&allocator);
//...填充数据
legacy_api(std::vector<int>(tmp.begin(), tmp.end()));
}
可以链式组合不同分配器:
cpp复制class logging_allocator : public pmr::memory_resource {
void* do_allocate(size_t bytes, size_t align) override {
log_allocation(bytes);
return upstream->allocate(bytes, align);
}
//...
};
实现固定大小对象分配:
cpp复制template<typename T>
class object_pool {
monotonic_buffer_resource alloc;
public:
T* create() { return new(alloc.allocate(sizeof(T))) T(); }
};
在实际游戏引擎开发中,我们通常会将monotonic_buffer_resource与其他分配策略组合使用。比如在UE4中,每个游戏线程会维护:
这种分层设计既保证了性能,又提供了足够的灵活性。当我们需要进一步优化时,可以通过自定义上游分配器接入内存映射文件或持久化内存,这在大型开放世界游戏的流式加载中尤为重要。