当内存池不是一个好主意时避免内存碎片

Avoid memory fragmentation when memory pools are a bad idea

我正在开发一个 C++ 应用程序,其中程序 运行 无休止地随着时间的推移分配和释放数百万个字符串 (char*)。 RAM 的使用是程序中的一个重要考虑因素。这导致 RAM 使用率随着时间的推移越来越高。我认为问题是堆碎片。我真的需要找到解决方案。

从图中可以看出,在程序中经过数百万次分配和释放后,使用量一直在增加。而我测试它的方式,我知道它存储的数据并没有增加。我猜你会问,“你怎么确定?”,“你怎么确定这不仅仅是内存泄漏?”,嗯。

这个测试运行要长得多。我运行malloc_trim(0),只要有可能就在我的程序中。看起来,应用程序最终可以 return 未使用的内存到 OS,它几乎变为零(我的程序当前的实际数据大小)。这意味着问题不是内存泄漏。但我不能依赖这种行为,我程序的分配和释放模式是随机的,如果它永远不会释放内存怎么办?

所以我的问题是,在随着时间的推移发生数百万次分配和释放的应用程序中,程序员做什么(我应该做什么),并且它们的大小不同,因此内存池很难有效使用。我无法更改程序的功能,我只能更改实现细节。

赏金编辑: 尝试使用内存池时,是否可以创建多个内存池,以至于每个可能的字节数都有一个池?例如,我的字符串可以介于 30-4000 字节之间。所以不能有人为程序的每个可能的分配大小制作 4000 - 30 + 1, 3971 个内存池。这不适用吗?所有池都可以从小开始(不会丢失太多内存),然后在性能和内存之间取得平衡。我并不是想利用内存池的能力来预先保留大 spaces。我只是想有效地重用释放的 space,因为频繁的分配和释放。

最后编辑: 事实证明,图表中出现的内存增长实际上来自我程序中的 http 请求 queue。我没有看到我所做的数十万次测试使这个 queue(类似于 webhook)变得臃肿。而图 2 的合理解释是,我最终从服务器中禁止了 DDOS(或者由于某种原因无法再打开连接),queue 被清空,RAM 问题得到解决。因此,以后阅读此问题的任何人,请考虑每一种可能性。我永远不会想到它是这样的。不是内存泄漏,而是实现细节。我仍然认为@Hajo Kirchhoff 值得赏金,他的回答真的很有启发性。

如果一切确实is/works如您所说,并且没有您尚未发现的错误,那么试试这个:

malloc 和其他内存分配通常使用 16 字节的块,即使实际请求的大小小于 16 字节。所以你只需要4000/16 - 30/16 ~ 250个不同的内存池。

const int chunk_size = 16;

memory_pools pool[250]; // 250 memory pools, managing '(idx+1)*chunk_size' size

char* reserve_mem(size_t sz)
{
    size_t pool_idx_to_use = sz/chunk_size;
    char * rc=pool[pool_idx_to_use].allocate();
}

IOW,你有 250 个内存池。 pool[0] 分配和管理长度为 16 字节的 chunk。 pool[100] 管理 1600 字节等的块...

如果你事先知道字符串的长度分布,你可以根据这些知识为池预留初始内存。否则我可能会以 4096 字节为增量为池保留内存。

因为虽然 malloc C 堆通常以 16 字节的倍数分配内存,但它会(至少在 Windows 之前,但我猜,Linux 在这里是相似的)询问 OS 用于内存 - 通常适用于 4K 页面。 IOW,由操作系统管理的“外部”内存堆保留和释放 4096 字节。

因此,将您自己的内部内存池增加 4096 字节意味着 OS 应用程序堆中没有碎片。这个 4096 页大小(或......的倍数)来自处理器架构。 Intel 处理器的内置页面大小为 4K(或 4K 的倍数)。不知道其他处理器,但我怀疑那里有类似的架构。

所以,总结一下:

为每个内存池的字符串使用 16 字节的倍数的块。 使用 4K 字节的倍数的块来增加内存池。

这将使您的应用程序的内存使用与 OS 的内存管理保持一致,并尽可能避免碎片化。

从 OS 的角度来看,您的应用程序只会增加 4K 块的内存。这很容易分配和释放。而且没有碎片。

从内部 (lib) C 堆管理的角度来看,您的应用程序将使用内存池并且每个字符串最多浪费 15 个字节。所有类似的长度分配也会堆在一起,所以也没有碎片。

这里有一个解决方案,可能会有所帮助,如果您的应用程序具有以下特征:

  • 在 Windows
  • 上运行
  • 有剧集,其中工作集很大,但所有数据都是在大约同一时间点完全发布的。 (如果数据以某种批处理模式处理,工作完成后释放相关数据)。

此方法使用一个(独特的?)Windows 功能,称为自定义堆。也许,您可以在其他 OS.

上为自己创建类似的东西

您需要的函数在一个名为 <heapapi.h> 的 header 中。

这是它的样子:

  1. 在程序的内存密集型阶段开始时,使用 HeapCreate() 有一个堆,所有数据都将放在堆中。
  2. 执行内存密集型任务。
  3. 在内存密集阶段结束时,释放您的数据并调用 HeapDestroy()

根据您的应用程序的详细行为(例如,此内存密集型计算是否在 1 个或多个线程中运行),您可以相应地配置堆,甚至可能获得一点速度(如果只有 1 个线程使用数据,你可以给 HeapCreate() HEAP_NO_SERIALIZE 标志,这样它就不需要锁。)你也可以给出堆允许存储的上限。

一旦您的计算完成,销毁堆也可以防止长期碎片化,因为当下一个计算阶段到来时,您会从一个新的堆开始。

Here is the documentation of the heap api.

事实上,我发现这个功能非常有用,所以我在 FreeBSD 上复制了它,Linux 用于我移植的应用程序,使用虚拟内存函数和我自己的堆实现。几年前,我没有那段代码的权利,所以我不能展示或分享。

您还可以将此方法与固定元素大小的池结合使用,对一个专用块大小使用一个堆,然后在每个堆中期望 less/no 碎片(因为所有块都具有相同的大小)。

struct EcoString {
  size_t heapIndex;
  size_t capacity;
  char* data;
  char* get() { return data; }
};

struct FixedSizeHeap {
  const size_t SIZE;
  HANDLE m_heap;
  explicit FixedSizeHeap(size_t size) 
    : SIZE(size)
    , m_heap(HeapCreate(0,0,0)
  {
  }
  ~FixedSizeHeap() {
     HeapDestroy(m_heap);
  }
  bool allocString(capacity, EcoString& str) {
    assert(capacity <= SIZE);
    str.capacity = SIZE; // we alloc SIZE bytes anyway...
    str.data = (char*)HeapAlloc(m_heap, 0, SIZE);
    if (nullptr != str.data)
      return true;
    str.capacity = 0;
    return false;
  }
  void freeString(EcoString& str) {
     HeapFree(m_heap, 0, str.data);
     str.data = nullptr;
     str.capacity = 0;
  }
};

struct BucketHeap {
  using Buckets = std::vector<FixedSizeHeap>; // or std::array
  Buckets buckets;
/*
(loop for i from 0
           for size = 40 then (+ size (ash size -1))
           while (< size 80000)
           collecting (cons i size))
((0 . 40) (1 . 60) (2 . 90) (3 . 135) (4 . 202) (5 . 303) (6 . 454) (7 . 681)
 (8 . 1021) (9 . 1531) (10 . 2296) (11 . 3444) (12 . 5166) (13 . 7749)
 (14 . 11623) (15 . 17434) (16 . 26151) (17 . 39226) (18 . 58839))
  Init Buckets with index (first item) with SIZE (rest item) 
in the above item list.
*/
 // allocate(nbytes) looks for the correct bucket (linear or binary 
 // search) and calls allocString() on that bucket. And stores the 
 // buckets index in the EcoString.heapIndex field (so free has an 
 // easier time).
 // free(EcoString& str) uses heapIndex to find the right bucket and
 // then calls free on that bucket.
}

您正在尝试做的事情叫做 slab 分配器,这是一个研究得很好的问题,有很多研究论文。

您不需要所有可能的尺寸。通常平板有 2 种尺寸的幂。

不要重新发明轮子,有很多 slab 分配器的开源实现。 Linux 内核使用一个。

首先阅读有关 Slab 分配的维基百科页面。

我不知道你的程序的细节。处理记忆的模式有很多种,每种模式都会导致不同的结果和问题。但是,您可以查看 .Net 垃圾收集器。它使用几代对象(类似于内存池)。该世代中的每个对象都是可移动的(它在内存中的地址在世代压缩期间发生变化)。这种技巧允许“compress/compact”内存并减少内存碎片。您可以实现具有类似语义的内存管理器。根本不需要实现垃圾收集器。