冰山之下:使用new申请内存的背后

起因

客户在使用动态链接库的过程中发生异常,种种原因导致目前无法获取到该库的源码,客户方面也没有给出足够的信息,包括交叉编译工具链、系统配置等。由于是生产库,输出信息也少的可怜,但是不管怎么样还是要恰饭的,要恰饭就要解决问题,也就是说只能硬着头皮上,至少先缩小问题的范围。唯二的信息就是库抛出的std::bad_alloc异常,这是C++中动态申请内存失败后抛出的,以及内核报出的内存不足(OOM)。
虽然好像板上钉钉,罪魁祸首就是内存不足,但是从提出申请到内核批复内存不足中间程序还很多,我们需要有充分的证据才能信誓旦旦的说这就是内存不足引起的,你加内存条吧。毕竟,如果一开始方向错了,再努力也解决不了问题。
想要解决问题,就得先知道异常从哪来;想知道异常从哪来就要知道动态申请内存的原理。虽然千头万绪,但还不至于无从下口。总得来说,需要解决以下几个问题:

  1. 动态内存从哪里来;
  2. 动态内存怎么申请;

##动态内存从哪里来

进程地址空间

一般编程语言都会介绍内存分配有两种方式:动态分配和静态分配。静态分配就是编译的时候已经确定了内存的大小和位置;动态分配就是程序在运行过程中动态申请内存,内存的大小和位置都是编译的时候不能确定的。静态的容易理解,需要多少内存创建进程的时候就知道了,但是动态分配,存在着不确定性,怎么去处理?对于这个问题,不同系统有不同的内存分配策略,由于这里涉及到的问题出现在Linux上,因此以下主要围绕Linux下的内存分配策略展开。

Linux是个多任务操作系统,同一时段内有多个进程在运行着,它们各自拥有很多的资源,其中比较重要的资源之一就是它们拥有的内存。但毕竟内存资源是有限的,不管你4G、8G或者是64G内存,总归是有个尽头。一个进程可能就耗费几百上千兆内存,操作系统怎么让有限的内存运行更多的进程?解决办法是虚拟内存技术。

操作系统将管理的内存抽象成了一个虚拟内存,这个虚拟的内存用一个线性地址来表示。例如,32位的系统的拥有一个从0到 232 这么大的一个线性地址,也就是4GB,而64位的系统拥有的线性地址非常大:0~264,从目前来看这个虚拟内存的容量是可以满足任何进程的需求的。这个线性地址被分成了许多段,每一段大小相当,一般是4KB,每一段称为一页。而真的物理内存也被分为相同大小的一块一块区域,称为帧。这样,虚拟内存的一页就能和物理内存的一帧相映射,同一个物理内存的帧可以与多个虚拟内存的页做映射,而物理内存的每一帧又可以和辅存如硬盘上的文件相映射,如下图1所示。操作系统通过换页机制(paging),这就相当于可拥有了一块远大于物理内存的虚拟内存。

举个栗子,如图1所示,有两个虚拟内存的页映射到了同一个物理内存帧,但同一时间内只能有一个页和帧绑定。某一时刻,frame存储的是page1的内容,现在程序访问到了page2,由于page2的目前还没和frame绑定,就会触发缺页中断(page fault)。操作系统先挂起当前程序,然后中断处理程序就会将目前物理内存帧内的内容存储到磁盘block1,而把block2的内容读到frame,然后将page2绑定到frame,最后恢复被挂起的程序的执行。通过这一换页机制,程序就拿到了page2上的存储的内容。当又需要page1的内容的时候,同样的方法得到,而应用程序并不知道这些,在应用程序看来,它需要的数据一值都在内存中。

1585322797803.jpeg

有了这么大的内存,同时运行多个进程就变成了可能。操作系统为每个进程分配了一个互不重叠的线性地址空间(Linear Address Space),这个线性地址空间可以有多个内存区域(Region)组成,每个进程的地址空间是相互独立的。例如下图2所示,分别用两种颜色表示出了两个进程相互独立的地址空间。
Picture 2 Process Address Space
Linux使用一个mm_struct的结构体来存储进程地址空间的信息,对于地址空间中的每一个区域,使用vm_area_struct结构体来存储,这些一个个区域按照地址递增的顺序使用单向链表链接在一起,如图2所示。并且为它们构建了一棵红黑树,使得在数量庞大内存区域中检索特定的一个区域的时候也能获得很好的检索性能1
mmap.png

进程空间的获取

Linux操作系统为每一个进程分配了一个互不重叠的线性地址空间,这个地址空间由一到多个连续的内存区域(memory region)组成。这个地址空的得来分为两部分:

  1. exe*()、fork()等系统调用创建一个新的进程的时候为进程分配的地址空间,里面存储这进程的代码,静态数据等。这部分地址空间会一直存在直到进程结束;
  2. 进程运行过程中通过mmap()、brk()系统调用动态申请获得的地址空间,这部分地址空间可以随着使用完毕而释放。

了解了进程地址空间的概念,就可以分析OOM的可能因素了。进程地址空间可以抽象的分为三段:代码段、数据段和栈段2exec()等创建进程分配的地址空间是不用考虑的,因为程序可以运行起来了说明这个阶段的内存是够用的。主要就看mmap()、brk()这几个扩展进程地址空间的系统调用。
虽然都是扩展地址空间,但是mmap()3brk()4的机制并不一样。brk()扩展的地址空间数据段,能扩展的前提是扩展的地址空间不能与别的数据段重叠,也不能超过系统设定的数据段的最大值(rlimit)。而mmap()将地址空间中一个区域与一个文件或者文件的某一部分做个映射,能映射成功的前提是还有足够大小的区域可以映射,另外如果mmap()指定了MAP_FIXED标志位,那么给定的地址开始的一段地址空间必须满足一定要求。

可以看到,使用不同的方法扩展地址空间可能得到的结果是不一样:可能进程数据段已经无法扩展但是其他地方还有空间可以映射文件。因此OOM的出现还可能和标准库的实现有关,不同的标准库可能会选择不同的方式去扩展地址空间。

内存怎么分配

标准库

我们经常说的C/C++,只是一个标准,这个标准可大致分为两部分:语言特性和编程接口。这个标准决定了这个语言能做什么,有那些功能,但是最终决定程序怎么写的,是编译器。因此必须有人按照标准的描述去实现一个编译器,让那些按照这个标准编写代码的人能够将他们的代码编译到目标机器上。对于大多数编译器而言,都会严格执行标准去实现。但由于有着众多的软硬件,不会有哪个实现能在所有平台上都是最优的,因此针对一些特定的软硬件组合,某些编译器实现会做一些调整或者扩展。拿做菜举个栗子,大家都按照一个菜谱来做菜,什么时候该多大火候、放什么调料都基本一样,但是不同人可能有些不同需求。比如虽然大家都作出了鱼香肉丝,但是张三觉得如果不放点香菜那这道菜就莫得灵魂;李四不吃香菜,看到这鱼香肉丝里面有香菜就相当于放了敌敌畏,这根本没法吃。那李四只能自己重新做一个没有香菜的鱼香肉丝。

既然某个编译器是没办法覆盖所有软硬件组合的,那标准库也不可能通用,毕竟标准库最终也要通过编译器编译。因此,就出现了很多不同的标准库实现。编程接口(API)标准只是给出了接口应该长什么样以及调用后可以得到的结果,这就给了不同的实现自由发挥的空间。例如动态申请内存的接口是malloc(),返回的是一个申请到的内存的地址:有的实现可能使用brk()这个系统调用去扩展数据段获得更多地址空间;而另外一些实现可能使用mmap()系统调用去映射外部文件来扩展地址空间。下面简单介绍几种不同的标准库:

  1. libstdc++: GNU’s libstdc++,在GNU/Linux上的C++标准库5的一个实现版本,GCC的一部分,一般的Linux发行版都会默认使用这个实现6
  2. libc++:一个针对C++11、C++14以及更高的C++标准的标准库的实现版本,主要是在Apple’s OS上替代libstdc++,也是LLVM编译链的一部分,在Apple’s OS X上全部实现了C++11标准7。同时,libc++是 Android OS Lollipop之后所使用的C++标准库,也是NDK r18之后唯一使用的C++标准库8
  3. libstdcxx:Apache的一个C++标准库的实现,从官网上看最后一次发布是在2008年;
  4. STLport: STLport projec实现的一个C++标准库;
  5. glibc:GNU/Linux上的C标准库的一个实现版本,目前Linux上使用最广泛的一个C标准库版本,主流的Linux发行版默认的C库;
  6. libc:曾经用来在Linux上替代glibc的一个C库,但是后来随着glibc2.0的发布,由于glibc性能优于libc,Linux的发行版又将C库逐渐切换回到了glibc,目前Linux上libc.so.6其实就是glibc2.0的一个符号链接,都是一个东西9
  7. MSVC:微软的工具包,里面包含了C++标准库的实现。

程序动态申请内存流程

由于我们的问题是在Linux下的,因此我们就将libstdc++拎出来看看它是怎么申请内存的。
在C++中,我们想要动态申请内存,用到的是new或者new[]重载中的一个,它内部会调用glibc的malloc()方法去申请内存,如下面代码所示:

// https://github.com/gcc-mirror/gcc/gcc/libstdc++-v3/libsupc++/new_op.cc

_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
  void *p;

  /* malloc (0) is unpredictable; avoid it.  */
  if (__builtin_expect (sz == 0, false))
    sz = 1;

  while ((p = malloc (sz)) == 0)
    {
      new_handler handler = std::get_new_handler ();
      if (! handler)
	_GLIBCXX_THROW_OR_ABORT(bad_alloc());
      handler ();
    }

  return p;
}

因此,我们最终需要看glibc中的malloc()是如申请内存的:
glibc的开源地址是https://sourceware.org/git/glibc.git,使用以下命令可以获得当前的稳定版本:

git clone git://sourceware.org/git/glibc.git
cd glibc
git checkout release/2.31/master

malloc

glibc的malloc10是用堆(heap)来动态分配内存。除了堆,还可能有数组、位图等方法分配内存。前面说过,每个进程有自己独立的地址空间,在这些地址空间上,可能分布着一个或者多个堆,这些堆就是由malloc来管理。堆中已分配的地址空间所有权属于进程,而尚未分配的地址空间所有权属于malloc。等等,所有地址空间不应该都是属于进程么,怎么会存在未分配的所有权属于malloc这种说法?
别急,让我们用学校举个例子。假设学校是进程,学校里的宿舍楼是堆,国家是操作系统,学生就是数据。学校会让后勤的人来给学生安排宿舍,对于已经住了学生的宿舍,学校就让辅导员直接接管了,而那些学生毕业了腾空的宿舍,辅导员是不会去理的,当下一届学生来,谁住在哪个房间,后勤说了算,因此分配的权力就归到了后勤那里,就相当于所有权属于后勤。万一扩招了现有宿舍容纳不下怎么办,跟国家申请经费建设新的宿舍楼呗。

malloc负责托管进程的堆,它将堆(heap)分成了不同大小、连续的一块一块(chunk),每一块记录该块的大小、是否已分配等信息。由于进程中可能存在多个线程,为了降低竞争带来的消耗,malloc将多个块组成一个竞技场(arena),不同的线程操作不同的竞技场,这样就能降低并发的时候不必要的等待。堆、块、竞技场的关系入下图所示:
1585464862982.jpeg
从进程的角度看,如果所申请的内存不是特别大(到底多大称为特别大,由malloc决定),那么就从malloc目前管理的内存中找一个大小合适的块,如果没有大小合适块,可以将大的块拆分或者将连续的小块合并以满足需求;如果所申请的内存特别大,那么malloc直接通过mmap()系统调用去分配一块,使用完成后何时将它归还给系统也是由malloc决定。就比如跟人借钱应急,有的人应急,完事儿了有钱了他也不一定马上还,毕竟钱在他手上他才是大爷。当然也不是一直不还,当程序原本的空间看着有富于了,malloc会选择将一些通过mmap()获得的空间归还系统,毕竟如果大家都不还,耗尽了系统资源谁也没好果子吃。
从线程的角度,如果某个线程去申请内存,如果它之前已经申请过,那么之前在哪个竞技场申请的就去哪个竞技场申请,如果该竞技场被别的线程锁了那就得等着;如果之前没申请过,那么malloc会按情况为它开辟个新竞技场或者复用目前空闲的。一般情况下,竞技场的数量是处理器核心数的两倍,毕竟如果竞技场高出核心数太多最终也还是要排队,效果也是打了折扣。

总结

回到我们最初的问题:内存分配失败,从用户角度看到的是std::bad_alloc,从内核的角度看到的是OOM(Out Of Memory)。通过分析,我们可以确定下一步思路:

  1. 确定客户的交叉编译链,看使用的是什么标准库;
  2. 如果最终使用glibc,根据malloc的机制,申请已经委托到了系统那边,那么就要去分析系统配置,例如系统的对资源使用的限制等等;
  3. 如果不是glibc,又得去分析对应的分配策略。

虽然可能说我们的库这边是无辜的,但也有所启发。从malloc的机制看,申请大块内存的时候才会调用mmap()去问系统要内存(也可能申请小内存但申请的太多耗光了原本的),是否可以再优化,降低内存消耗?

公众号二维码

首发于个人微信公众号TensorBoy。微信扫描上方二维码或者微信搜索TensorBoy并关注,及时获取更多最新文章!
C++ | Python | 推理引擎 | AI框架源码,有一起玩耍的么?

References


  1. Understanding the Linux Kernel, 3rd Edition # Chapter 9 ↩︎

  2. https://www.gnu.org/software/libc/manual/html_node/Memory-Concepts.html#Memory-Concepts ↩︎

  3. Linux Programmer’s Manual - MMAP(3) ↩︎

  4. Linux Programmer’s Manual - BRK(2) ↩︎

  5. https://github.com/gcc-mirror/gcc ↩︎

  6. Linux Programmer’s Manual - LIBC(7) ↩︎

  7. http://libcxx.llvm.org/ ↩︎

  8. https://developer.android.google.cn/ndk/guides/cpp-support ↩︎

  9. http://man7.org/linux/man-pages/man7/libc.7.html ↩︎

  10. https://sourceware.org/glibc/wiki/MallocInternals ↩︎

发布了45 篇原创文章 · 获赞 4 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/ZM_Yang/article/details/105181571
今日推荐