关于学校的按需寻呼,他们不会告诉你什么?™

2020-10-17 10:06:53

这篇文章详细介绍了我使用Linux虚拟内存子系统的经历,以及我发现了一种创造性的方法,通过在内核而不是在用户空间积累内存来嘲弄OOM(内存不足)杀手。

一个可爱的被OOM杀手杀死的方式,同时看起来占用很少的内存(非常适合聚会)。

像往常一样,故事从我询问有关实现细节的问题开始。这一次,是关于Linux内核的请求分页实现。

在操作系统101中,我们了解到操作系统在向进程分配内存时是“懒惰的”。当您mmap()匿名页面时,内核会立即狡猾地返回一个指针。然后,它会等待,直到您通过“触摸”该内存触发页面错误,然后再执行实际的内存分配工作。这就是所谓的“按需寻呼”。

这是高效的-如果内存从未被触及,则不会分配任何物理内存。这也意味着您可以分配远远超过物理可用内存的虚拟内存(“过量使用”),这可能很有用。1.你就是摸不到所有的东西。

让我们潜入更深的地方。除非执行,“触摸”记忆意味着读或写。写入新的mmap‘d区域需要内核执行完整的内存分配。您需要内存,现在就需要,内核不能再推它了。

与写入不同,对新mmap区域的读取不会触发内存分配。内核通过利用必须将新的匿名映射初始化为零的方式来继续推送分配。内核不分配内存,而是使用“零页”来处理页错误:预分配的物理内存页,完全用零填充。从理论上讲,这是“免费的”--单个物理帧可以支持所有零初始化的页面。

重点是什么?请求分页是细微差别的-并不是所有访问新映射的方式都需要内核分配内存。

让我们看看源代码中的这个是什么样子。核心页面错误处理程序HANDLE_MM_FAULT位于mm/ememy.c中。通过__HANDLE_MM_FAULT和HANDLE_PTE_FAULT进行了几个深度调用,我们遇到了这个块:

静态VM_FAULT_t HANDLE_PTE_FAULT(结构VM_FAULT*vmf){//...如果(!vmf->;pte){if(vma_is_anous(vmf->;vma)返回DO_ANONOWARY_PAGE(Vmf);否则//...}//...}。

静态VM_FAULT_t do_ANNOWARY_PAGE(struct VM_FAULT*vmf){//...IF(pte_alloc(vma->;vm_mm,vmf->;pmd))return VM_FAULT_OOM;//.../*使用零页读取*/IF(!(vmf->;标志&;FAULT_FLAG_WRITE)&;&;//(1)!mm_forbids_zeropage(vma->;Vm_mm)){Entry=pte_mkspecial(pfn_pte(my_zero_pfn(vmf->;address),//(2)vma->;VM_PAGE_PROT);VMF->;PTE=PTE_OFFSET_MAP_LOCK(vma->;VM_mm,vmf->;pmd,vmf-&>地址,&;vmf->;ptl);//...转到Setpte;}//...setpte:set_pte_at(vma->;vm_mm,vmf->;address,vmf->;pte,entry);//(3)//...返回ret;//...}。

对啰。它检查读取是否导致错误(1),然后将虚拟页映射到零页(2和3)。

请注意,这一切都发生在页面错误处理程序中,这是一种实现选择。Mmap核心逻辑根本不接触页表,并且只记录新映射的存在。它使映射的页表条目不存在(当前位=0),这将在访问时触发页面错误。

或者,mmap可以主动分配页表条目,并将其初始化为零页。这将避免第一次读取时出现页面错误,但代价是提前初始化(可能会有许多)页表条目。考虑到最大程度的懒惰是最有效的,那么当前的实现是最好的。

这就引出了另一个问题。既然来自匿名映射的读取是“自由的”,那么除了分配过多的虚拟内存之外,难道您不能实际接触到所有的虚拟内存吗?只要那个“触摸”是一种阅读?

实验时间到了。下面是一些代码,它分配100 GB的线性内存,并尝试从每页的第一个字节读取。它一次分配512MB,因为您不能直接向mmap请求100 GB:)。2我的测试系统是x64 Ubuntu20.04VPS。

#include<;sys/mman.h>;#include<;iostream>;const size_t MB=1024*1024;const size_t GB=MB*1024;int main(){size_t alloc_size=512*MB;size_t total_alloc=100*GB;size_t num_allocs=total_alloc/alloc_size;//std::cout<;<;";alloc_size(MB)";<;<;Alloc_size/(1024*1024)<;<;";\n";;//std::cout<;<;";Total_alloc<;";\n";;//std::cout<;<;";num_allocs";<;<;num_allocs<;<;&34;\n";;std::cout<;<;";分配内存...\n";;char*base=nullptr;//为(size_t i=0;i<;num_allocs;i++){//错误警报-假设分配是连续的并逐渐减少。Base=(char*)mmap(NULL,ALLOC_SIZE,PROT_READ,MAP_PRIVATE|MAP_ANOUNTY,-1,0);IF(BASE==MAP_FAILED){perror(NULL);抛出std::Runtime_Error(";FAIL";);}std::cout<;<;(void*)base<;";<;<;i<;<;";\n";;}std::cout<;<;";已分配虚拟内存(GB):";<;<;Total_alloc/GB<;<;";\n";;std::cout<;<;";基本地址:";<;<;(void*)基本<;<;";\n";;标准::cout<;<;";按Enter键开始读取。\n";;getchar();std::cout<;<;";读取每页的第一个字节...\n";;//读取每页的第一个字节(size_t i=0;i<;total_alloc;i+=0x1000){auto x=bas[i];}std::cout<;<;";完成!\n";;getchar();}。

$./demo正在分配内存...0x7f3f6d300000 10x7f3f4d300000 20x7f3f2d300000 3...0x7f26cd300000 1980x7f26ad300000 1990x7f268d300000 200已分配的虚拟内存(GB):100BASE Addr:0x7f268d300000按Enter开始阅读。

它成功地以512MB块为单位分配了100 GB的线性虚拟内存。我们可以使用PMAP确认这一点,它在打印的基址显示一个100 GB的匿名虚拟内存区域。

$pmap`pidof demo`485209:./demo00005600e1d0c000 4K r-demo00005600e1d0d000 4K r-demo00005600e1d0e000 4K r-demo00005600e1d0f000 4K r-demo0000005600e1d10000 4K rw--demo00003f5600e2a47000 132K rw--[anon]00007f268d300000 1048600K r--[anon]<;100 GB region7f3f8d000 16K RW--[on0000007f3f8d304000 60K r-libm。

HTOP确认分配了100 GB的虚拟内存(VIRT专栏),但是更合理的154KB驻留内存(RES)实际上正在占用RAM。请注意MINFLT列-这是已发生的“次要”页面错误数。次要页面错误是指不需要从磁盘加载的错误。我们将触发大量这样的事件,应该会看到这个数字戏剧性地增长。

0x7f26cd300000 1980x7f26ad300000 1990x7f268d300000 200分配的虚拟内存(GB):100BASE地址:0x7f268d300000按Enter开始读取,读取每页...完成!

这个过程点击“完成!”打印出来。这意味着它成功地触及了100 GB分配的每一页!

HTOP确认发生了许多小故障。我们预计它会导致26214400个错误(100G/4KB),实际上,26214552-26214400=152%,这是我们开始时的数字。有趣的是,常驻内存似乎也增加了,这是不应该发生的。有关这方面的讨论,请参阅附录A。

所以这个理论被证实了!显然,您可以“分配内存,也可以触摸它”(只要该触摸是读操作)。3个。

如果您的应用程序出于某种原因受益于拥有100 GB的零数组,那么这非常适合您。那我们其他人呢?

更接近现实的应用程序是稀疏数组。稀疏数组是一个(通常非常大的)数组,其元素大多为零。通过利用按需分页,您可以实现内存效率高的稀疏数组,其中大部分数组由零页支持(甚至不映射)。在避免内存开销的同时,您可以获得数组的快速索引优势。

我有件事要坦白。还记得我说过不是所有的内存访问方式都需要内核分配内存吗?是啊,那是谎话。

尽管共享零页可以服务于读取,但这并不意味着没有分配内存。即使映射零页的过程也需要分配内存。这就是我们进入细节的地方。

开销来自虚拟内存基础设施本身-页表。页表是为虚拟内存子系统提供动力的数据结构。与常规数据结构一样,它们占用内存,只是它们的开销很容易被忽略,因为它对用户空间是隐藏的。

它们是4层深的树数据结构,每个节点(表)是512个8字节条目的数组。这些表一起提供了一种有效的方式来表示地址空间中的每个虚拟页和物理帧之间的映射。

这就是开销的来源。所接触的每个页面都需要分配1个页表条目(PTE)。但是,PTE不是单独分配的。它们被分配在称为页表的512个块中。每个页表需要分配1个页目录条目。但是页面目录条目也不是单独分配的,它们是以512个称为页面目录的块来分配的。这会一直传播到树的顶层:PML4表(“Page Map Level 4”表)。只有一个PML4表,它由CPU的CR3寄存器指向。

请注意,虽然“页表”指的是特定类型的表,但这些表都通俗地称为“页表”。

由于页表条目为8字节,且所有页表包含512个条目,因此得出页表为4096字节。这看起来不是很多,但是要映射一个页面,每个表都需要一个。这已经是16KB的开销了。如果您要映射千兆字节的虚拟地址空间,则此开销将会增加。

这就引出了最后一个问题。具体地说,页表开销加起来可以达到多少?页表是否可能占用一些不可忽略的内存部分?是否有可能完全从页表耗尽内存?为此,我们需要映射多少虚拟内存?

下面是计算虚拟内存分配的页表开销的伪代码。

输入:Virtual_Pages_allocatedpage_table_Entries=virtual_page_allocatedpage_tables=ceiming(page_table_entry,512)/512page_tables_bytes=page_tables*4096page_dirs=ceiming(page_tables,512)/512page_dirs_bytes=page_dirs*4096pdp_tables=ceiming(page_dirs,512)/512pdp_tables_bytes=PDP_Tables*4096pml4_table=1pml4_table_bytes=1*4096输出:Total_Overload=page_tables_bytes+page_dirs_bytes+\pdp_tables_bytes=pml4_table_bytes=1*4096输出:Total_Overload=page_tables_bytes+page_dirs_bytes+\pdp_tables_bytes=pml4_table_bytes=1*4096输出:Total_Overload=page_tables_bytes+page_dirs_bytes+\pdp_tables_bytes。

请记住,表是作为整体分配的。即使表被部分填满,它仍然占据全部4KB。这就是为什么计算需要向上舍入到最接近的512的倍数(在Excel中表示为上限函数)的原因。

使用此方法,我们计算出分配512 GB的虚拟内存需要略高于1 GB的页表!

虚拟机分配GB 512字节分配549755813888页分配134217728PTE 134217728页表2621445页表字节1073741824页目录512页目录字节2097152PDP表1PDP表字节4096PML4表1PML4表字节4096总开销字节1075847168总开销GB 1.001960754

我的机器只有1 GB的内存,所以这应该足以耗尽内存,理想情况下,还会触发OOM杀手。

又到了做另一个实验的时候了!我将上面的代码更改为分配512 GB而不是100 GB,并重新运行它。

...0x7eca126ba000 10220x7ec9f26ba000 10230x7ec9d26ba000 10224分配的虚拟内存(GB):512Base Addr:0x7ec9d26ba000按Enter开始读取。正在读取每页...fish:';./demo2';由信号SIGKILL(强制退出)终止。

啊,真灵!。几分钟后,内存耗尽,OOM杀手终止了该进程。

尽管OOM分数非常高,但驻留内存仍然非常低。这表明页表开销不会影响驻留内存。

底部显示/proc/*/status的VmPTE字段,确认页表使用了接近800MB的内存(接近物理内存限制)。

照片很漂亮,但看到现场直播更令人兴奋。

请注意,代码首先执行所有mmap,然后触及内存。这是故意的。在mmap‘ing之后立即接触映射的另一种方法不会起作用,因为mmap最终会失败。这将防止我们分配更多内存并将系统推向极限。

当我们预先mmap所有内容,然后触摸页面时,我们利用了过度提交,这将允许mmap在绕过物理内存限制之后很长一段时间才能成功。然后,我们利用这样一个事实,即有效的内存访问不可能“失败”-必须处理页面错误。

有了足够安静的系统(加上一些运气),就有可能在OOM杀手的日志中获得堆栈跟踪:

这是触发OOM终止的分配的内核堆栈跟踪。在跟踪的中间,我们发现了熟悉的函数:handlemmfault和do匿名page。这是我们前面看到的匿名页面的页面错误处理程序。

此跟踪显示,触发OOM杀手的最终分配是我们自己尝试映射另一个零页-这一操作在理论上是“免费的”,但在实践中可能会导致您的OOM死亡。

如果您足够仔细地查看请求分页,您会发现细微差别。写操作显然会触发分配,但是读操作可以由共享的零页面有效地提供“空闲”服务。

这使应用程序能够分配大量的虚拟地址空间并访问所有这些空间!只要他们只想读取(零)。这实际上可以应用于元素大多为零的稀疏数据结构。

这就是说,应该警告那些热衷于虚拟内存的程序员-虽然用户空间看不到“自由”的零页映射,但实际上“自由”的零页映射并不是免费的,而且可能会从虚拟内存基础设施本身产生大量的内存开销。

页表只是系统内存开销的几个来源之一(每个线程的内核堆栈是另一个例子)。通常,应用程序开发人员可以安全地忽略这些问题,但我希望这篇文章能让我们一窥负责提供这种安全性的系统开发人员的世界。

你从这篇帖子里学到什么了吗?我很想听听是什么--发推特给我@offlinemark!

如果你想知道我什么时候写新帖子,我还有一个邮件列表:

感谢Jann Horn帮助理解常驻记忆峰值。感谢杰克·米勒、罗德里克·莫里斯、詹姆斯·拉里希和威廉·伍德拉夫审阅了这篇文章的早期草稿。

摘要:如果您需要高精度地测量驻留内存使用情况,请不要使用HTOP(或/proc/*/{statm,status})。请改用/proc/*/smaps或/proc/*/smaps_roll up。

在这些实验中,当触发对所有页面的读取时,驻留内存中会出现一个神秘的1.5MB峰值。情况不应该是这样-如上所述,缺省为零页只会分配页表,而不会计入驻留内存。在执行读取之前立即报告的驻留内存应该在剩余的执行过程中保持不变。

我很困惑,并确信我发现了一个内核错误。我确认用户空间中没有发生任何会导致这种情况的事情-页面出错循环的主体中只有一条内存访问指令。单步执行确认此指令单独触发尖峰。我还确认了HTOP的数据源/proc/*/statm也显示了这个峰值,所以这不是HTOP错误。

令我失望的是,事实证明这不是一个错误。(感谢Jann Horn给我看了这个。)。

/proc中的不同文件提供不同级别的准确性。这没有记录在手册页中,但是在内核的内部文档和这个堆栈溢出帖子中提到了。HTOP从/proc/*/statm接收内存统计信息,该信息故意提供较低的精度度量,以换取更好的多线程性能。为了避免锁争用,内核在每个线程缓存中记录内存使用情况,然后在每次64页错误后将这些内存使用与进程范围的缓存同步。

因此,我观察到的“峰值”实际上并不是峰值-它是达到同步阈值,它更新/proc/*/statm中的进程范围信息。驻留内存实际上总是3MB,只是报告层需要一些时间才能更新,因为我的程序恰好在64故障周期中间的getchar()上停止。

如果您需要高精度的内存信息,请使用/proc/*/smaps和/proc/*/smaps_roll up。以牺牲性能为代价,这些文件提供了更高的准确性,因为它们的实现遍历内部数据结构,而不是使用内部缓存。

还有一个小谜团:如果统计数据每64页错误同步一次,怎么会累积1.5MB的错误呢?每64个故障同步一次表明最大错误为4KB*63个故障=252KB错误。

詹恩和我都被这事难住了。如果您能帮助解释这一点,请与我联系。LKML讨论在这里。

这是懒惰、肮脏的研究代码:)它既不健全,也不便携,只是因为Linux碰巧将mmap放在连续的位置,在内存中不断向下增长,所以它才能正常工作。

或者,如果你愿意,可以引用亨利·福特(Henry Ford)的名言:“只要你只是在阅读,你想触摸多少记忆就能触摸到多少。”