fwalker 发表于 2022-3-8 19:52

Boehm GC—unity3d 2018 il2cpp的几个问题笔记

先说比较好的地方,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误判其中的数据为指针。

Arzie100 发表于 2022-3-8 19:53

大佬,我遇上一个不断申请零时变量内存导致managedHeap.UsedSize 不断增大的问题,能否提供一些方向https://forum.unity.com/threads/a-weird-memory-heap-leak-issue-on-android.832864/,感谢

Doris232 发表于 2022-3-8 19:56

1、你这个问题感觉有点奇怪,因为你分配的内存都清0了,感觉不应该再被blacklist标记上(除非引擎分配的内存里面有这种疑似指针的数据并且恰好解析成指针后指到了你分配的内存块上)。

RhinoFreak 发表于 2022-3-8 20:06

2、还有一个你可以试试,就是unity/mono有一个奇怪的行为,调8次GC会调整mono堆的大小,你试着连续调8次GC再看看呢。

kyuskoj 发表于 2022-3-8 20:13

像第一种情况大概会是什么操作导致的呢?第二个方法的话我明天立马就试试

JamesB 发表于 2022-3-8 20:15

第一种具体原因我也不太清楚,之前看到比如AB包加载之类,unity本身也会有托管内存分配,不知道你这个例子用了没

redhat9i 发表于 2022-3-8 20:22

学习了;主要还是Mono内存的使用问题,优化分配有所缓解可能只是假象。

IT圈老男孩1 发表于 2022-3-8 20:30

连续6次gc命中

LiteralliJeff 发表于 2022-3-8 20:36

实测是8次,你说的6次是munmap那个判断吧

xiaozongpeng 发表于 2022-3-8 20:42

大佬 为什么分配大内存块的时候才需要用blacklist过滤,而小内存块不需要
页: [1] 2
查看完整版本: Boehm GC—unity3d 2018 il2cpp的几个问题笔记