浅谈虚拟内存与项目开发中的OOM问题

Posted Jerish_C

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈虚拟内存与项目开发中的OOM问题相关的知识,希望对你有一定的参考价值。

这是【游戏开发那些事】第55篇原创

文章中关于Windows平台虚拟内存的部分内容转自知乎用户 “南京周润发”

内存管理是操作系统中极为核心且重要的内容,也是游戏开发者极为头痛的问题之一。由于游戏在研发中需要加载大量的美术资源,频繁的创建与销毁各种对象,就很容易遇到各种内存问题,比如最常见的OOM,"Out of Memory"(OOM)。

>>UE4触发OOM逻辑

一般来说,我们很难从OOM产生的Dump文件中立刻发现问题,崩溃的具体原因也有多种,如:

  • 某个位置的代码由于逻辑问题错误申请了大量内存

  • 内存泄漏,导致可用的内存资源越来越少,最后崩溃

  • 逻辑代码不规范,频繁的申请大量空间并常驻内存

  • 系统同时运行了大量进程,内存空间确实比较吃紧

有些情况是我们的程序代码问题所引发,有的则可能与当时的系统环境(运行条件,设置)有关,通常我们需要结合Dump以及一些输出Log来分析当时的具体情况。比如下面这个日志截图,显示游戏程序在最后遇到了OOM问题并打印了当前的内存数据(Windows 64位,16G内存),可以看到虽然虚拟内存几乎用完了(11MB),但是最后当前机器还有不少物理内存(9GB)。

>>触发OOM打印的内存相关Log

对于有一定计算机基础的同学,应该知道现代操作系统进程的地址空间都是虚拟内存地址空间,32位系统理论上可以有4G的虚拟内存,64位的系统最高可以有16777216T的虚拟内存,为什么在物理内存足够的情况下仍然触发了OOM呢?分析内存问题,我们不能只局限于项目代码,需要从系统层面上尽可能的挖掘与深入。

虚拟内存概述

存储器是计算机的核心部件之一。理想情况下,我们希望CPU在执行指令前可以快速的(最好快过指令的执行时间)从存储器里面获取大量的数据,从而高效的发挥电子计算机的“存储计算”功能。但现实条件下,由于硬件限制我们不得不将存储器设计成如下的“寄存器——高速缓存——主存——硬盘”分层结构来尽可能的满足目标。

缓存和寄存器的空间非常有限,其设计目的更多的为了提高计算与存取速度。相比之下,主存与硬盘存储技术的发展才能更好的应对应用程序日益增长的内存需求。在早期的系统中,内存(主存)也是非常昂贵的资源,如果有大量进程都需要申请物理内存,那必定会有许多进程由于内存不足而无法运行。为了更加有效且安全的管理内存,现代操作系统对内存提出了一个非常重要的抽象概念——虚拟内存,并以此来进行内存资源的管理。通俗的讲,虚拟内存就是把内存看成硬盘的高速缓存,内存上只存储当前频繁被使用的内容,其他的先扔到硬盘上缓存起来。如果你想用硬盘上的某个内容,就把这块内容换到内存上去。

虚拟内存主要解决的问题:

  • 内存大小的限制,大量进程需要大量内存,但其实有很多内存内容很少被访问到,没必要持续占用内存

  • 一致的地址空间,方便程序写代码与进行内存管理

  • 保护进程的空间安全,避免进程之间的互相影响

虚拟内存技术涉及硬件异常、汇编器、链接器、加载器、共享对象、文件、进程等几乎系统的方方面面,所以这块内容在计算机的任何领域都非常重要。一般来说,我们需要理解虚拟如下几大块内容:

  • 1.操作系统使用分页机制管理虚拟地址空间中的内存,也就是以页为单位进行虚拟地址到物理地址的映射以及换入换出。通常一个页的大小是4KB。(下图的VP就是一个页)

  • 2.从虚拟地址转换的物理地址需要一个表来进行索引与关联,这个表就是页表。页表本质上就是一个map,根据key(虚拟页号Virtual Page Number,简称 VPN)得到对应的value(物理页框号Physical Page Number,简称 PPN,也就是物理地址的基址)。页表是常驻内存的,如果内存空间很大,可以设计成多级页表,一级页表常驻内核空间。

  • 3.虚拟内存空间对于所有进程都是一样的(比如32位系统每个进程的虚拟内存空间都是0X00000000-0XFFFFFFFF),所以我们需要通过一个转换(地址翻译)来访问真正的物理空间,负责转换的单元叫做MMU,Memory Management Unit,MMU 将虚拟地址翻译成物理地址之后再通过内存总线去访问物理内存:

  • 4.翻译与映射过程,页表负责存储、MMU负责计算。具体细节还涉及到虚拟空间/物理空间地址的组织与设计,页表的设计等

其他TLB加速,翻译细节,多级页表,内核实现等内容大家可以参考《深入理解计算机系统》《深入理解Linux内核》等资料

https://hansimov.gitbook.io/csapp/part2/ch09-virtual-memory/9.6-address-translation
https://www.youtube.com/watch?v=ZjKS1IbiGDA
https://zhuanlan.zhihu.com/p/370092684

回归问题

在理解了虚拟内存概念的基础上,我们可以回头再去分析前面的OOM问题。按理说,每个进程都有自己独立的虚拟地址空间,只和操作系统位数有关,64位系统上,就是2^64Byte=16777216T。但如果完全支持64位地址空间,操作系统的页表实现会很复杂,而且通常进程也用不到这么大空间(硬盘一般也用不到),因此目前Windows只支持到48位虚拟地址空间。其中高47位给系统使用,用户进程能分配到的只有47位=128TB,这样也很大了。(此外,早期硬件厂商也会对地址总线进行限制,比如Intel在2015年的64位系统上只提供了最大64TB的地址空间即48位地址寻址,2022年已经放开了限制。对于32位系统则是一直维持在线性逻辑地址空间2^32Byte=4GB,最大物理地址空间2^36Byte=64GB)

>>Intel官方手册

https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html

官方有一个软件,Testlimit,可以看当前系统的虚拟地址空间限制,内部是不断用VirtualAlloc Reverse虚拟内存。本地测试结果如下,确实为128TB。(其实Windows对物理内存也有限制,比如Win10 64位的企业版也只能使用最大6TB的物理内存)

Testlimit - Windows Sysinternals | Microsoft Docs 
https://docs.microsoft.com/zh-cn/sysinternals/downloads/testlimit
https://docs.microsoft.com/zh-cn/windows/win32/memory/memory-limits-for-windows-releases?redirectedfrom=MSDN#physical-memory-limits-windows-10

那现在的问题是,我的进程明明可以有这么大的虚拟内存空间,怎么就用尽了呢?问题就出在了虚拟内存的非主存部分——分页文件。

分页文件

虚拟内存以页为管理单位,对内存进行换入换出。如果物理内存不足了,就以某种淘汰机制,把一些物理内存中的页换出到磁盘上,这样物理内存就有空间存放新的内容。磁盘上储存的这些页就称为分页文件(Linux上称为Swap交换空间)。

当应用程序申请内存时,其实是磁盘上的一块空间,内存只是作为一个加速缓存结构。程序能申请的内存有多少,首先受限于虚拟地址空间(64位系统空间大小足够可以不考虑这个限制),然后就是剩余磁盘空间了。

大致意思为OOM错误与实际有多少物理内存无关
https://docs.microsoft.com/en-nz/archive/blogs/ericlippert/out-of-memory-does-not-refer-to-physical-memory 
“Out Of Memory” Does Not Refer to Physical Memory

我们的硬盘空间也有限制,操作系统自然不会把所有的磁盘用于分页文件,如图我们可以在Windows上进行设置。操作系统会根据系统负载控制分页文件所占空间大小,当前大概29GB(都在C盘),负载大了会自动增长(有极限),当然我们也可以手动自定义大小。

系统管理器中也可以看,

  • 使用中:已使用的物理内存大小

  • 已提交:前面的数字是所有进程已申请的内存,可以简单理解为malloc调用申请总数,后面数字是系统总共支持的虚拟内存大小,为物理内存+分页文件大小。

提交的含义与Windows内存操作接口有关,malloc调用在Windows中等于Reverse + Commit,Reverse只是在页表中记录一下虚拟内存地址状态,对物理内存、磁盘都没有任何消耗

分页文件是所有进程公用的,默认对用户透明,磁盘里不可见,但可以通过选项打开。

这样就能在C盘看到名称为pagefile.sys的分页文件了。

实验测试

1.申请物理内存极限

在一台16GB电脑上进行实验,初始内存状态如下:

不断malloc内存,直到返回NULL,此时内存状态

看起来45GB是上限,此时磁盘中分页文件大小为33GB。这个上限估计是系统根据物理内存大小和磁盘剩余空间综合计算得到的。

如果把自动管理模式改成手动,把C盘中分100GB给分页文件用。那么当malloc到返回NULL时,内存状态为

从中也能看到还有1.5GB的物理内存可用。看下C盘,已经少了很多空间了

2.磁盘空间不足

假如磁盘空间不足,分页文件也会变得很小吗?

先把C盘空间占满,只剩大概3G空间

然后不断malloc,最终虚拟内存上限就很小了

综合以上资料和实验,通常两个情况会导致Windows上的OOM

  • 1 运行游戏时,已经运行了太多其他程序,把分页文件用完了,因为默认的管理方式分页文件有上限

  • 2 磁盘空间不足,分页文件无法扩容

    那如果现在物理内存非常充足,C盘几乎没有空间,这时候申请1G内存会崩溃么?答案是不会,OOM是物理空间+磁盘分页总数不够的时候才会崩溃。

在Windows上,我们可以打印分页文件的大小来辅助查证。下图的virtual memory应该理解为虚拟内存空间(不要直接理解为虚拟内存),paging file则是分页文件大小,PageFileUseage表示当前进程分页文件的使用大小。主要用到了Windows api的Global Memory StatusEx和GetProcess Memory Info。

拓展:Linux上的虚拟内存与内存分配

在Linux系统上,程序在被编译后就会在对应的elf格式的文件中记录各个区域的虚拟内存地址,比如代码段(.text)数据段(.data)。当加载到内存时,就会被映射到虚拟内存的各个位置上。

虚拟内存被组织成如下所示的区域,每个进程维护一个单独的任务结构task_struct来记录这些区域的位置信息。

Linux上与分页文件相似的概念叫做交换空间(Swap空间),我们可以通过free命令查看内存的相关信息。

关于分页文件/Swap空间有一点需要强调——页文件/Swap空间不等于虚拟内存,或者说虚拟内存在没有硬盘参与下也可以运行。虚拟内存是现代操作系统上标配的抽象机制,可以利用硬盘上的空间进行存储,在物理内存空间不够的情况下将当前不被使用的内存换到硬盘上去。但是我们完全可以把这个选项项关闭(Windows在设置中选择无分页文件,Linux执行sudo swapoff -a),让所有进程申请的空间都映射到物理内存上去,这样的好处是我们可以避免内存和硬盘之间的交互开销,但是也更容易触发OOM,所以一般建议要在内存充足/系统环境比较稳定的情况下使用。

当然,当我们实际运行C语言中的malloc(Linux上根据申请大小可能调用brk或者mmap,ptmalloc内存分配器实现)或者直接调用某些系统调用的时候,很有可能只是申请一块虚拟内存空间,这些空间并不会直接映射到物理内存上去。当我们去访问这块内存的时候,才会触发缺页中断,然后真正的分配并映射到物理内存,在Linux上一般称为内存的延迟分配机制。总的来说,不同的操作系统行为可能不同,Windows对于小的内存申请一般会直接调用heapalloc直接映射到对应的物理内存(结论来自网络,待查证)。

结语

这篇文章主要针对实际项目的开发情景,探讨并挖掘一些之前学习中被忽略的细节和模糊的概念。内存问题往往千奇百怪,需要从原理上理解才能更好的去解决。不过想要深入理解虚拟内存、搞懂内存分配器原理肯定还需要更进一步的研究和学习,包括调试项目源码、Linux源码、查看各种文档手册等,后面有新的学习成果还会与再次与大家分享的~

 往期文章推荐 

游戏开发技术系列【想做游戏开发,我应该会点啥?】

虚幻引擎技术系列【使用虚幻引擎4年,我想再谈谈他的网络架构】

游戏科普系列【盘点游戏中那些“欺骗玩家眼睛的开发技巧”】

C++面试系列【史上最全的C++/游戏开发面试经验总结】

我是Jerish,网易游戏工程师,5年从业经验。该公众号会定期输出技术干货和游戏科普的文章,关注我回复关键字可以获取游戏开发、操作系统、面试、C++、游戏设计等相关书籍和参考资料。

以上是关于浅谈虚拟内存与项目开发中的OOM问题的主要内容,如果未能解决你的问题,请参考以下文章

浅谈虚拟内存与项目开发中的OOM问题

浅谈虚拟内存与项目开发中的OOM问题

拯救OOM 字节自研 Android 虚拟机内存管理优化黑科技 mSponge

Android面试每日一题: 哪些情况下会导致oom问题?

Android面试每日一题: 哪些情况下会导致oom问题?

Android中内存泄露与如何有效避免OOM总结