|
先说比较好的地方,Boehm GC提供了很多回调机制,然后unity2018已经接入了gc resize、gc alloc、gc collect event等回调,我们只需要对源码进行很小的修改就可以在真机上探查回调时的GC内存状况,比如可以记录调用栈或通过标记记录GC流程。
我们的项目最近一直有奇怪的GC跳变,有时甚至直接涨100MB,通过对前述的回调栈的跟踪,发现几点奇怪的地方:
1、跳变时会出现多次16MB大小的连续堆增长,但堆增长之间并没有新的内存分配请求。
2、引起堆增长的分配请求只请求了不到300KB的内存。
通过抓取当时的mono内存数据,参照memory profiler源码把数据提取出来,分析后发现,堆增长的多个16MB内存区域上并没有任何mono对象,也就是说感觉像是空白内存区域!
这些奇怪的现象让我们开始怀疑是GC内存管理本身出问题了。
于是我开始翻gc的源码,这部分源码其实unity是公开的,就在Editor/data/il2cpp下。不过,真的是读不懂。。大概知道这是一个第三方的开源库,历史悠久,大约是20世纪80年代后期开始写的。其实这里透露了一个信息,这个GC库显然不大可能依赖mono的实现细节,所以它并不是基于mono对象来管理内存的,从作者网站的介绍大概知道它是基于内存数据位模式来识别指针,然后建立分配块之间的引用关系的。这看起来不是一个100%可靠的机制。
因为回调栈提供了一些信息,所以我们可以围绕回调栈上的函数来阅读代码,这样可以省点时间,也更聚焦我们的问题。
#1:profiling::gc_resize_event(void *,long long)
#2:il2cpp::vm::Profiler::GCHeapResize(long long)
#3:GC_collect_or_expand
#4:GC_alloc_large
#5:GC_generic_malloc
#6:GC_malloc_kind_global
#7:il2cpp::vm::Object::AllocatePtrFree(uint,Il2CppClass *)
#8:il2cpp::vm::Array::NewSpecific(Il2CppClass *,uint)上面是堆扩展的il2cpp栈,所以可以看看GC_collect_or_expand:
/* Collect or expand heap in an attempt make the indicated number of */
/* free blocks available. Should be called until the blocks are */
/* available (setting retry value to TRUE unless this is the first call */
/* in a loop) or until it fails by returning FALSE. */
GC_INNER GC_bool GC_collect_or_expand(word needed_blocks,
GC_bool ignore_off_page,
GC_bool retry)
{
GC_bool gc_not_stopped = TRUE;
word blocks_to_get;
IF_CANCEL(int cancel_state;)
DISABLE_CANCEL(cancel_state);
if (!GC_incremental && !GC_dont_gc &&
((GC_dont_expand && GC_bytes_allocd > 0)
|| (GC_fo_entries > (last_fo_entries + 500)
&& (last_bytes_finalized | GC_bytes_finalized) != 0)
|| GC_should_collect())) {
/* Try to do a full collection using 'default' stop_func (unless */
/* nothing has been allocated since the latest collection or heap */
/* expansion is disabled). */
gc_not_stopped = GC_try_to_collect_inner(
GC_bytes_allocd > 0 && (!GC_dont_expand || !retry) ?
GC_default_stop_func : GC_never_stop_func);
if (gc_not_stopped == TRUE || !retry) {
/* Either the collection hasn't been aborted or this is the */
/* first attempt (in a loop). */
last_fo_entries = GC_fo_entries;
last_bytes_finalized = GC_bytes_finalized;
RESTORE_CANCEL(cancel_state);
return(TRUE);
}
}
blocks_to_get = (GC_heapsize - GC_heapsize_at_forced_unmap)
/ (HBLKSIZE * GC_free_space_divisor)
+ needed_blocks;
...我们看到blocks_to_get的计算,这公式看起来是个经验公式,有点像移动平均的算法,会把历史数据按比例添加到当前申请的值上。 我们遇到的连续多次的16MB的堆增长,显然与这个计算有关,通过添加日志观察,发现GC_heapsize_at_forced_unmap的值一直是0!这感觉上是出Bug了,boehm gc其实提供了一个函数来修改这个变量,但unity并没有调用过。这样,将会直接把当前堆大小的1/3叠加到申请数上,我们申请300KB,如果当前堆大小是100MB,那么这里算出的结果会是30多MB,这已经超过了boehm gc堆增长的上限16MB,所以实际增长是16MB,比我们实际需要的大太多了。。(堆增长也有个下限,256KB)
我们最后选择按申请值的2倍扩展,但最多增长1MB,除非申请值大于1MB,此时按实际申请数增长。由于增长下限是256KB,多数情形增长这个值就够了。
这样修改后突变情况有所缓解,但还是没有控制住。还得继续研究,网上有几篇介绍boehm GC的文章,但都只是简单说了一下小内存分配的情况(小于2KB),我们遇到的问题主要出在大内存分配时。
boehm gc和常见内存管理库类似,主要采用空闲空间链表来管理待分配内存。
对于小内存分配,32位下,它按8字节的大小增量分别维护了256个空闲链表(正好2KB,64位下,是16字节增量,128个空闲链表,也是2KB),所以小内存分配请求直接按请求size去相应的空闲链表里找就可以,看起来效率比较高,但8字节对齐下,碎片比例也比较高(我们的经验数据大约在50%)
大内存分配要稍微复杂一些,此时内存块是4KB一块,boehm gc也是维护了许多不同大小的空闲链表,前32个就是按块大小来,第一个4KB,第二个8KB,......类推。然后是每8个块大小一个,供28个,更大size的块都在最后那个链表上。这样子,正常按size算,最后那个链表的size是4*32+4*8*28=1024KB,看来boehm gc主要考虑1MB以下的内存管理,这在mono或il2cpo用于游戏的情形还是很不错的,我们基本上只有不到100次超过1MB的分配需求。
了解了空闲链表的大概结构后,可以看一下分配的源码,核心逻辑在GC_allochblk与GC_allochblk_nth函数里,boehm大概是按请求size找到空闲链表,然后看是否有恰好一样大的块,如果没有,则重新在这个链表找一次合适的(此时不要求大小一样了),如果还是没有,就去更大块的空闲链表里找,找到了就把空闲块拆成两块,并按大小重新放到对应大小的链表上。这是一个比较正常的逻辑了。
前面提到过,boehm并不能100%准确的判断引用关系,所以实际分配时,并不是一个空闲块的大小够用就会选用,它还维护了一blacklist,是从大内存块边界(4KB)到一个标记位的哈希。如果一个空闲块对应着这样一个标记,那就说明有疑似指针的数据指向了这块内存,也就是像被用了一样,此时,boehm会跳过这段内存。这就是它说它是保守GC的原因,同时也是浪费内存的一个原因。
如果不涉及堆增长,这个策略看起来最大的问题就是明明有空间却无法分配,也就是会浪费一定量内存(也可以归于碎片)。
在堆增长的情况,实际上在决定增长堆之前,已经尝试了一次分配,但失败了。那么刚刚增加的内存块,显然应该就是候选块。但boehm的分配逻辑,此时仍然会尝试找尽量接近的块,这个看起来没有必要,我们可以标记一下分配是否正好在堆增长之后,如果是,就直接使用合适的第一块就好了,这样可以快一点。
到这里,连续多次分配16MB的原因我们仍然没有找到,但已经提到了,那就是blacklist,刚刚新添加到空闲链表里的内存块,有可能是在blacklist中,减去相应的不可用区域的大小后,剩余大小可能就不够了。而且我们观察到在我们项目里,这种情况可能会连续导致堆增加产生的内存块都不可用,直到mono堆增长了一百兆才找到可用的地方。。
这其实是保守GC策略的副作用。考虑到游戏里我们有机会来释放启动后分配的数据,只要不是因为启动阶段就产生了这样的blacklist,我们是可以优先使用刚刚堆增加产生的内存块的,与保守策略相比,其实最坏情形也是内存的浪费,但我们是已经用了后浪费,相比保守情形直接浪费100MB的空内存,还是可以接受的。另外,策略改的稍稍激进一些,我们可以给上层逻辑一定的调整策略的灵活性,这在保守策略下是没有可能的。
改动其实并不大,就是如果分配是发生在堆增长后,那就优先使用而不是再去增长堆。
另外一个对上层逻辑代码有用的启示是,比较大块(2KB以上)的mono内存不用的时候,应该zero memory一下,这样会避免boehm误判其中的数据为指针。 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|