背景

写 C++ 服务或者引擎代码时,经常会碰到一种情况:CPU 看着不高,但延迟就是压不下去。

最后一 profile,热点不在算法,也不在锁,而是在 operator newoperator delete

这类问题在下面几种场景里尤其常见:

  • 网络服务里大量创建短生命周期请求对象
  • 游戏引擎里频繁分配小型组件
  • 消息队列消费者持续构造临时 buffer

如果对象大小固定,或者分布相对集中,内存池通常是很直接的一刀。

为什么默认分配器会成为瓶颈

通用分配器要解决的问题很多:

  • 不同尺寸的内存申请
  • 跨线程竞争
  • 碎片整理
  • 对齐要求

这些能力都很重要,但它们也意味着额外开销。

如果你的场景很单一,比如“每次都申请一个 256 字节的请求对象”,那继续走通用分配器其实是在为用不到的能力买单。

一个简单的固定块内存池

先看一个足够说明问题的版本。

#include <cstddef>
#include <new>
#include <vector>

class MemoryPool {
public:
    MemoryPool(std::size_t blockSize, std::size_t blockCount)
        : block_size_(blockSize) {
        data_.resize(blockSize * blockCount);

        for (std::size_t i = 0; i < blockCount; ++i) {
            void* ptr = data_.data() + i * blockSize;
            free_list_.push_back(ptr);
        }
    }

    void* allocate() {
        if (free_list_.empty()) {
            throw std::bad_alloc();
        }

        void* ptr = free_list_.back();
        free_list_.pop_back();
        return ptr;
    }

    void deallocate(void* ptr) {
        free_list_.push_back(ptr);
    }

private:
    std::size_t block_size_;
    std::vector<char> data_;
    std::vector<void*> free_list_;
};

思路很直接:

  1. 启动时一次性申请一大块内存
  2. 按固定大小切成很多块
  3. 用空闲链表管理可用块

优点也很明显:

  • 分配是 O(1)
  • 回收是 O(1)
  • 没有额外碎片问题

和对象生命周期配合

内存池只负责“给你一块内存”,对象构造和析构还是要自己管。

template <typename T>
class ObjectPool {
public:
    explicit ObjectPool(std::size_t count)
        : pool_(sizeof(T), count) {}

    template <typename... Args>
    T* create(Args&&... args) {
        void* memory = pool_.allocate();
        return new (memory) T(std::forward<Args>(args)...);
    }

    void destroy(T* object) {
        if (object == nullptr) {
            return;
        }

        object->~T();
        pool_.deallocate(object);
    }

private:
    MemoryPool pool_;
};

这里用到了 placement new。它只做构造,不分配内存。

struct RequestContext {
    int fd;
    std::string path;
    std::string trace_id;
};

int main() {
    ObjectPool<RequestContext> pool(1024);

    auto* ctx = pool.create();
    ctx->fd = 10;
    ctx->path = "/api/orders";
    ctx->trace_id = "abc123";

    pool.destroy(ctx);
}

什么时候值得上内存池

不是所有代码都该自己写分配器。

我一般只在这几种场景考虑:

  1. profile 证明确实有分配热点
  2. 对象大小相对固定
  3. 对象生命周期短且数量大
  4. 对尾延迟敏感

如果只是偶尔创建几个对象,直接 new/delete 更简单,也更不容易出错。

线程安全问题

上面的实现是单线程版本。到了多线程环境,就得认真处理并发访问。

常见方案:

  • 每线程一个本地池,减少锁竞争
  • 中央池 + 线程本地缓存
  • 无锁 free list,但实现复杂度明显上升

在高并发服务里,我更倾向于线程局部池 + 批量回收。因为真正影响尾延迟的,很多时候不是分配本身,而是锁竞争和 cache miss。

常见坑点

1. 池化了不该池化的对象

std::stringstd::vector 这种内部自己还会分配堆内存的对象,池化外层对象不一定解决根问题。

2. 对齐被忽略

如果块大小和类型对齐要求不匹配,很容易埋下未定义行为。

3. 回收顺序与生命周期不清晰

对象还在被引用,就提前放回池里,这类 bug 会比普通野指针更难查。

4. 把内存池当银弹

很多性能问题本质上是对象设计太碎、拷贝太多、缓存局部性太差。内存池能救一部分,但不能替代整体设计优化。

总结

C++ 性能优化里,内存分配一直是个绕不开的话题。通用分配器很强,但你的业务未必需要那么通用。

当 profile 已经明确告诉你热点在频繁分配和释放上时,一个设计克制的内存池,往往比继续抠函数内几行逻辑更有效。

前提也很明确:先测量,再优化;先确认场景,再决定是否值得引入复杂度。


性能优化最怕的是凭感觉动刀,profile 过的数据,才配得上复杂实现。