当内存池不适用时,如何避免内存碎片问题

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

Ram usage Report 1

你可以在图像中看到,在程序中进行了数百万次分配和释放后,使用情况仍在增加。根据我的测试方式,我确信它存储的数据并没有增加。我猜想你会问:“你如何确定这一点?”、“你如何确定这不仅仅是内存泄漏?”好吧。

enter image description here

这个测试运行时间更长。我在程序中尽可能地运行malloc_trim(0),似乎应用程序最终可以将未使用的内存返回给操作系统,并且它几乎降到了零(当前我的程序实际数据大小)。这意味着问题不是内存泄漏。但我不能依赖这种行为,我的程序分配和释放模式是随机的,如果它永远不释放内存会怎么样?
  • 我在标题中说过,内存池对这个项目来说不是一个好主意。当然我没有绝对的知识。但我分配的字符串可以是30-4000字节之间的任何内容。这使得许多优化和聪明的想法变得更加困难。内存池就是其中之一。
  • 我正在使用GCC 11 / G++ 11编译器。如果一些旧版本有糟糕的分配器,那么我不应该有这个问题。
  • 我如何获得内存使用情况?使用Python的psutil模块。proc.memory_full_info()[0],它给了我RSS
  • 当然,你不知道我的程序细节。如果确实是由于堆碎片导致的问题,这仍然是一个有效的问题。嗯,我能说的是,我会及时更新关于程序中发生了多少次分配和释放的信息。我知道程序中每个容器的元素计数。但如果你仍然有关于问题原因的一些想法,我愿意听取建议。
  • 我不能只为所有字符串分配4096个字节,以便更容易地进行优化。这与我的尝试相反。

我的问题是,当一个应用程序在不同大小的内存分配和释放操作中需要进行数百万次操作时,程序员(或我)该怎么做才能更高效地使用内存池?我无法改变程序所做的事情,只能改变实现细节。

Bounty Edit:当尝试利用内存池时,是否可以制作多个内存池,以至于每个字节计数都有一个对应的池呢?例如,我的字符串长度可能在30-4000个字节之间。那么,每个可能的分配大小都可以制作4000 - 30 + 1,3971个内存池。所有内存池都可以从小开始(因此不会损失太多内存),然后在性能和内存之间取得平衡。我并不是要利用内存池提前保留大空间。我只是想有效地重复使用已释放的空间,因为会频繁进行内存分配和释放。

最后编辑:事实证明,图表中出现的内存增长实际上来自于我的程序中的http请求队列。我没有意识到我进行了成千上万次测试,导致了这个队列的膨胀(类似于webhook)。图2的合理解释是,我最终被服务器DDOS封禁(或由于某些原因无法再打开连接),队列排空,RAM问题得到解决。所以,未来任何阅读此问题的人都要考虑到每一种可能性。我从未想过会是这样的情况,不是内存泄漏,而是实现细节。尽管如此,我仍然认为@Hajo Kirchhoff值得得到奖励,他的回答真的很有启发性。

@Passerby 感谢您的建议。我的程序可用的最大RAM取决于计算机/服务器的配置。我看到的存在的字符串数量最多为200M。但是,不断增加的RAM使用量很难管理,因为很难为此制定计划或预防措施。我正在考虑碎片整理,但默认的glibc分配器还无法实现(仍需决定使用什么替代方案)。这仍然比重启我的程序要好,因为那会对CPU和磁盘都造成负担和开销。 - Max Paython
你仍然可以使用内存池。我们分配固定大小的块(下一个二次幂)并对这些分配进行池化。虽然会浪费一些内存,但解决了碎片问题,因为池中从未有一个块不是相同大小的。我们还根据生命周期进行拆分。预计寿命更长的东西放在其他地方,池只用于常量翻转。 - Retired Ninja
3
我觉得仅仅是内存碎片化不足以导致 RAM 使用量随时间呈几乎完美的线性增加。我会在程序中插入诊断工具,用于计算当前分配字节数,从而估算碎片化比率。你是否直接使用 char 数组和 new/delete?你尝试过像 valgrind 这样的工具来获取更多信息吗? - MatG
如果您无法命名应用程序最坏情况下将使用多少内存的上限,那么您无能为力,因为统计学上在某个时候您将耗尽内存。如果您有一个比计算机提供的更大的上限,则需要重构,以使您的工作集始终保持有界。如果程序具有工作集大小的峰值,则可以考虑在Windows上使用自定义堆。 - BitTickler
1
@LouisGo 当然我考虑过它(和jemalloc),在评论中提到过。但考虑到我的分配模式,似乎不太可能有任何通用的内存管理解决方案能成功避免碎片化。 - Max Paython
显示剩余14条评论
4个回答

1
如果一切都像你所说的那样,并且没有任何你尚未发现的错误,那么请尝试以下操作:malloc和其他内存分配通常使用16字节的块,即使实际请求的大小小于16字节。因此,您只需要250个不同的内存池,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();
}

简化版: 你有250个内存池,每个池子负责管理不同长度的内存块。如果你预先知道字符串的长度分布,可以基于此为池子预留初始内存。否则,建议按照4096字节的增量为池子预留内存。
malloc C堆通常以16字节的倍数分配内存,但它会向操作系统请求内存,而操作系统通常以4K页面为单位工作。因此,将内部内存池扩大4096字节可以避免在操作系统应用程序堆中出现碎片。这样做可以使应用程序的内存使用与操作系统内存管理对齐,尽可能地避免分片。

谢谢你的回答。有时我需要处理超过2亿个字符串。如果每次增加4K字节的池,这可能会使池增长数千倍。这将创建许多池块(链接列表)。这会降低malloc/free的性能吗?还是与一次性分配相同。我不是在谈论块分配,而是在池内进行的数百万次free和alloc。 - Max Paython
只有在你的48字节内存池用尽时,才需要进行实际的(4K)内存分配,并且需要所有必要的开销。因此:速度更快 - 而且即使这个特定的内存池处理32到47长度之间的所有字符串,它也只会分配4K页面。 - Hajo Kirchhoff
长字符串的池子增加应该是4K的倍数。例如,将大小为1600...1615的池子增加16K或32K块。 - Hajo Kirchhoff
由于字符串长度与内存池页面大小的比率非常大(>100:1),因此分配/释放性能不再重要。如果您仍然发现它很重要,请进一步增加这个比例。也就是说,内存池“页面大小”可以是字符串长度的256倍。(对于64...79字节大小,使用5个4K = 20480字节)。 - Hajo Kirchhoff
恰恰相反。使用内存池中的固定大小块,您实际上可以拥有极快的线程安全实现,甚至可能是无锁的(除非需要增加池的大小)。有一些链接列表实现根本不使用互斥锁。这就是最快的方式了。 - Hajo Kirchhoff
显示剩余14条评论

1

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

  • 在Windows上运行
  • 具有集中式工作集的情况,但所有数据在完全相同的时间点释放(如果数据以某种批处理模式处理,并且在完成工作后,相关数据被释放)。

此方法使用了一种(独特的?) Windows功能,称为自定义堆。可能可以在其他操作系统上创建类似的东西。

你需要的函数在一个叫做<heapapi.h>的头文件中。

这是它看起来的样子:

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

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

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

这里是堆API的文档。

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

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

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.
}

谢谢你的回答。我确实是在Ubuntu环境中工作。而且我从来没有销毁过我的堆。只是堆内的元素会在随机时间间隔内随机地被回收、替换、删除或添加。 - Max Paython
我最近写了一些代码,可能对你有用。但是...它还没有完全实现功能,并且本质上具有一些“内部碎片”,即每个分配的项目都有一些内存开销。它基本上使得一个内存块是可移动的,这样它所在的页面就可以独立于分配模式进行碎片整理。但是它的代码太长了,不适合在这个网站上展示...它是一种没有垃圾的垃圾回收器。 - BitTickler

0
你正在尝试做的是一个叫做slab分配器的东西,这是一个经过深入研究的问题,有很多研究论文。
你不需要每个可能的大小。通常,slab以2的幂次方大小出现。
不要重复造轮子,有很多开源实现的slab分配器。Linux内核就使用了一个。
首先阅读维基百科上关于Slab Allocation的页面。

一开始我并不是想做这个。我试图学习所有值得一提的可能方法。根据建议和答案,它正在朝着那个方向发展。 - Max Paython
内存分配是一个自计算机科学早期以来就被广泛研究的问题 - 不要指望能够突然有所突破。它被认为是一个已经解决的问题。 - mmomtchev
感谢您的输入。我从来没有考虑过在第一时间实现任何内存管理系统。我只是试图了解现有的内存管理系统,并学习如何将其调整到我的特定情况(即一种策略)中。当我自己进行研究时,我感到困惑。因此,我在问题中加入了我的知识和问题 :) - Max Paython

0
我不知道你的程序细节。有许多处理内存的模式,每种都会导致不同的结果和问题。但是你可以看一下.Net垃圾收集器。它使用多个对象代数(类似于内存池)。该代中的每个对象都是可移动的(在压缩代时其内存地址会更改)。这种技巧允许"压缩/紧凑"内存并减少内存碎片化。你可以使用类似语义实现内存管理器。完全没有必要实现垃圾收集器。

谢谢你的回答。考虑到我正在使用原始的C指针,我在考虑一种机制,将双重指针存储在我的容器中,以允许碎片整理(例如在对象生命周期内移动对象)。但是为此,我需要一个池库,可以让我访问其内容,否则我就必须自己实现它。而且有很多事情需要考虑。 - Max Paython
@MaxPaython 这不是新范 Paradigm。我觉得已经有实现了。至少我在2017年的会议上听说过这个。 - Alex Aparin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接