栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 系统运维 > 运维 > Linux

跟我一起学Linux系统编程006C-进程内存分配,堆分配brk、malloc、free

Linux 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

跟我一起学Linux系统编程006C-进程内存分配,堆分配brk、malloc、free

 上上个记录《Linux系统编程006A-进程、内存布局》,上一个记录《Linux系统编程006B-进程内存布局、mmap调用、环境变量》中,主要是讲解了三件事:(1)进程与程序的区别(2)Linux进程内部的内存布局(3)Linux的整体虚拟地址的机制。本文要讲第四件事:进程的内存分配。

1 Linux进程内存布局的要点回顾:

(1)每个进程的虚拟地址空间分布

Linux使用虚拟地址空间,大增了进程的寻址空间,一个进程由低地址到高地址分别为:

  • 1 保留段:从0开始到0x08048000这一段。
  • 2 只读text段:只读空间,包括:代码段、rodata 段(C常量字符串和#define定义的常量)
  • 3 数据(data+BSS)段:保存全局变量、静态变量的空间
  • 4 堆heap:就是平时所说的动态内存, malloc/new 大部分都来源于此。其中堆顶的位置可通过函数 brk 和 sbrk 进行动态调整。
  • 5 mmap内存映射区:如动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。有的也称这个空间为:文件映射区。
  • 6 栈:用于维护函数调用的上下文空间,一般为 8M ,可通过 ulimit –s 查看。
  • 7 内核虚拟空间:用户代码不可见的内存区域,由内核管理(页表就存放在内核虚拟空间)。

(2)Linux 对所有虚拟内存进行管理的几个关键概念

  • 每个进程都有独立的虚拟地址空间,它并不是真正的物理地址;
  • 虚拟地址可通过每个进程上的页表(在每个进程的内核虚拟地址空间)与物理地址进行映射,获得真正物理地址;
  • 如虚拟地址对应物理地址不在物理内存中,则产生缺页中断,真正分配物理地址,同时更新进程的页表;如果此时物理内存已耗尽,则根据内存替换算法淘汰部分页面至物理磁盘中。

(3)32 位系统与64位系统,在进程空间上的差别

32位Linux系统有4G 的地址空间: 其中,用户空间位于0x08048000~0xbfffffff。内核空间位于0xc0000000~0xffffffff之间。内核空间,包括内核代码和数据、与进程相关的数据结构(如页表、内核栈)等。栈顶往低地址方向变化(%esp 执行);堆顶_edata往高地址方向变化(brk/sbrk 函数控制分配)。

64位系统,进程空间当然与32位不太一样。

64位系统拥有 2^64 的地址空间,但地址空间大小不是2^32,而一般是2^48。为啥不需要也不是2^64呢?

2^48 = 256TB,也就说只用48位就达到了256TB空间
2^64 = 256TB * 65536 = 16777216 TB

256TB已经够大的了,当真要用全部的64位来寻址空间。你是设计者也会自觉避免毫无意义的浪费。所以,事实也正是如此,64位Linux使用48位来表示虚拟地址空间,40位表示物理地址。

cat /proc/cpuinfo  
address sizes	: 39 bits physical, 48 bits virtual  

注意:上面是我在ubuntu20上的测试数据。个人总觉得“39 bits physical”,应该是“40 bits physical”,这可能是一个小的语意错误,因为程序对序号或索引值的计算,多数是由0开始!

所以,算下来,
0x0000000000000000~0x00007fffffffffff 表示用户空间, 0xFFFF800000000000~ 0xFFFFFFFFFFFFFFFF 表示内核空间,共提供 256TB(2^48) 的寻址空间。看着这种长长的数字,真是头大!(有人总结了:这两个区间的特点是,第 47 位与 48~63 位相同,若这些位为 0 表示用户空间,否则表示内核空间。这在编程时可能有用。)

64位进程在布局模式上与32位进程没有区别:用户空间地址由低到高,仍然是只读段、数据段、堆、文件映射区域和栈。

2 内存分配方式

有人说,第一,Linunx整体内存访问的机制是虚拟地址,它实现了物理与虚拟地址相互映射。第二,进程内存布局又由编译器和内核给限定了。稍不注意,比如访问进程保留区、进程内核空单,就会引发内存越界错误。

难道进程就没有一丁点的对内存进行管理的权力?

说句实话,进程的确受限太严,但并非是个奴隶,在内存访问方面还是有点自由度的。从操作系统角度来看,不考虑共享内存,进程分配内存有两种方式,分别由两个系统调用完成:brk和mmap。

  • mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。
  • brk是将数据段(.data)的最高地址指针_edata往高地址推;

这两种方式,其实分配的都是虚拟内存,没有分配物理内存。但在第一次访问已分配的虚拟地址空间的时候,发生缺页中断。这时候,内核就忙活起来,分配实际的物理内存,然后建立虚拟内存和物理内存之间的映射关系。

缺页中断发生后,进程会陷入内核状态,执行以下操作:

  1. 检查要访问的虚拟地址是否合法
  2. 查找/分配一个物理页
  3. 填充物理页内容(读取磁盘,或者直接置0,或者啥也不干)
  4. 建立映射关系(虚拟地址到物理地址)

我之前发布的记录文档中,讲到过mmap内存映射方式的概念,这里就略过,不再解释。

3 如何查看缺页中断

正如上面所说的缺页中断产生后,如果进行到第3步,需要读取磁盘,那么这次缺页中断就是majflt,否则就是minflt。

想查看缺页中断 可通过以下命令查看缺页中断信息。

ps -o majflt,minflt -C <进程名> 
ps -o majflt,minflt -p <进程pid> 

这两个命令的效果是一样的,第一条以进程名来查看,第二条是以进程ID来查看。比如我用gedit打开了一个文本文件。

$ ps -o majflt,minflt,uid,pid,user,group,cmd -C gedit

MAJFLT MINFLT UID PID USER GROUP CMD

39 8020 1000 7632 songguo+ songguo+ /usr/bin/gedit --gapplication-ser

$ ps -o majflt,minflt,uid,pid,user,group,cmd -p 7632

MAJFLT MINFLT UID PID USER GROUP CMD

39 8020 1000 7632 songguo+ songguo+ /usr/bin/gedit --gapplication-ser

其中, majflt是major fault,指大错误, minflt是minor fault ,指小错误。这两个数值表示一个进程自启动以来所发生的缺页中断的次数。 其中 majflt 与 minflt 的不同是, majflt 表示需要读写磁盘,可能是内存对应页面在磁盘中需要 load 到物理内存中,也可能是此时物理内存不足,需要淘汰部分物理页面至磁盘中。

4 堆内存分配malloc、堆内存释放free

在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk,mmap,munmap这些系统调用实现的。

下面是网友的论述,我只是改动了示意图中不当的地方(因为无法找到源头,所以无法摘录源网址,若有冒犯,必删)。

malloc分配内存的两种情况

第一种情况:malloc小于128k的内存,使用brk分配内存,将_edata往高地址推(只分配虚拟空间,不对应物理内存(因此没有初始化),第一次读/写数据时,引起内核缺页中断,内核才分配对应的物理内存,然后虚拟地址空间建立映射关系),如下图:

(1)进程启动的时候,其(虚拟)内存空间的初始布局如上图中的(1)部分所示。

其中,mmap内存映射文件是在堆和栈的中间(例如libc-2.2.93.so,其它数据文件等),为了简单起见,省略了内存映射文件。_edata指针(glibc里面定义)指向数据段的最高地址。

(2)进程调用A=malloc(30K)以后,内存空间如上图(2)部分所示。

malloc函数会调用brk系统调用,将_edata指针往高地址推30K,就完成虚拟内存分配。

你可能会问:只要把_edata+30K就完成内存分配了?

事实是这样的,_edata+30K只是完成虚拟地址的分配,A这块内存现在还是没有物理页与之对应的,等到进程第一次读写A这块内存的时候,发生缺页中断,这个时候,内核才分配A这块内存对应的物理页。也就是说,如果用malloc分配了A这块内容,然后从来不访问它,那么,A对应的物理页是不会被分配的。

(3)进程调用B=malloc(40K)以后,内存空间如图(3)部分所示。

第二种情况:malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配(对应独立内存,而且初始化为0,如下图:

(4)进程调用C=malloc(200K)以后,内存空间如上图(4)部分。

默认情况下,malloc函数分配内存,如果请求内存大于128K(可由M_MMAP_THRESHOLD选项调节),那就不是去推_edata指针了,而是利用mmap系统调用,从堆和栈的中间分配一块虚拟内存。

这样子做主要是因为:brk分配的内存需要等到高地址内存释放以后才能释放(例如,在B释放之前,A是不可能释放的,这就是内存碎片产生的原因,什么时候紧缩看下面),而mmap分配的内存可以单独释放。

(5)进程调用D=malloc(100K)以后,内存空间如上图(5)部分;

(6)进程调用free(C)以后,C对应的虚拟内存和物理内存一起释放,如上图第(6)部分。

(7)进程调用free(B)以后,如上图第(7)部分所示:

B对应的虚拟内存和物理内存都没有释放,因为只有一个_edata指针,如果往回推,那么D这块内存怎么办呢?

当然,B这块内存,是可以重用的,如果这个时候再来一个40K的请求,那么malloc很可能就把B这块内存返回回去了。

(8)进程调用free(A)以后,如上图第(8)部分所示:

A和B连接起来,变成一块70K的空闲内存。也可以称之为碎片。

(9)进程调用free(D)以后

当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。在上一个步骤free的时候,发现最高地址空闲内存超过128K,于是内存紧缩,变成上图第(9)部分所示。

请记住这一条原则,Linux内存管理的基本思想之一是:只有在真正访问一个地址的时候才建立这个地址的物理映射。

5 为什么不用mmap代替brk和sbrk。

前面说过,进程分配内存有brk和mmap这两种方式。上面又说了,C接口malloc申请堆内存,128K以下用brk方式,大于128K用munmap方式。brk方式看来容易产生堆内碎片,还不能直接释放,这样做疑似会带来“内存泄露”问题。而mmap分配的内存可以会通过 munmap 进行 free ,实现真正释放。

既然堆内内存brk和sbrk不能直接释放,为什么不全部使用 mmap 来分配,munmap直接释放呢?而是仅仅对于大于 128k 的大块内存才使用 mmap ?

原因之一,是想减轻内核资源负载。

进程向 OS 申请和释放地址空间的接口 sbrk/mmap/munmap 都是系统调用,频繁调用系统调用都比较消耗系统资源的。并且, mmap 申请的内存被 munmap 后,重新申请会产生更多的缺页中断。例如使用 mmap 分配 1M 空间,第一次调用产生了大量缺页中断 (1M/4K 次 ) ,当munmap 后再次分配 1M 空间,会再次产生大量缺页中断。缺页中断是内核行为,会导致内核态CPU消耗较大。另外,如果使用 mmap 分配小内存,会导致地址空间的分片更多,内核的管理负担更大。

原因之二,碎片重用机制。

堆是一个连续空间,并且堆内碎片由于没有归还 OS ,如果可重用碎片,再次访问该内存很可能不需产生任何系统调用和缺页中断,这将大大降低 CPU 的消耗。 因此, glibc 的 malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128k) 才使用 mmap 获得地址空间,也可通过 mallopt(M_MMAP_THRESHOLD, ) 来修改这个临界值。

6 验证

上面就是C语言的堆内存申请malloc函数、释放free函数的背后原理。当然,上面只是堆内存管理的基本原则,为了便于理解而图文描述的。现实当中,要比这复杂的多,尤其是在多线程场景下有更复杂的算法和策略。所以,如果想要验证上面的说法,只能原则符合,但细节绝不能一 一对照。下面这个代码就可以在命令行进行验证。

#include 
#include 
#include 
#include 
#define MAX_ALLOCATE   1000
int main(int argc,char * argv[])
{	
	if ( (argc < 3) || strcmp(argv[0],"--help") == 0)
	{
		printf("命令 : 内存块大小 块数n");
		return 0;
	}
	int unit_size = atoi(argv[1]);
	int block = atoi(argv[2]);
	char *ptr[MAX_ALLOCATE];
	
	printf("current brk : %10pn",sbrk(0));
	
	if (block > MAX_ALLOCATE)
		block = MAX_ALLOCATE;
	
	for(int i=0;i=0;--i)
	{
		free(ptr[i]);
		printf("free %d brk : %10p n",i, sbrk(0) );
	}
	return 0;
}

执行测试:

./a.out 32768 10 申请10块*32K字节(=320K字节)的堆内存空间

./a.out 16 1 申请1块*16字节(=16字节)的堆内存空间

./a.out 327680 2 申请2块*320K字节(=640K字节)的堆内存空间

具体的测试输出,略。

7 malloc、free使用原则

  • 按需要malloc申请堆内存,不要浪费
  • 不能默认malloc执行成功,必须判断返回值。返回为空则失败,返回地址则成功
  • 对于成功申请的malloc申请堆内存,不能越界访问。
  • 申请必释放。如果malloc申请的内存不需要释放,就用全局变量好了。否则,即使进程即将结束,也要释放。
  • 能不用堆内存,尽量不要用

8 brk模式与mmap模式的优缺点

brk()是一个非常简单的系统调用,只是简单地改变mm_struct结构的成员变量brk的值。实际使用中,需要复杂的结构进行管理,往往还会带来碎片等问题。但是,在Linux系统内核中,它是微秒级的响应时间。

而mmap使用相对简单些,但内核对它的响应可能是毫秒的反应时间。

brk()与mmap,两者性能有时会相差一个量级。这正是大于或小于128K,malloc内部采用联brk或mmap的原因所在。

9 理解堆操作(malloc、free)背后的管理机制(brk和mmap)的必要性

理解进程内存布局模型,对于在Linux平台下进行深度开发来说非常重要。堆内存管理机制,则是其中最重要、最难以理解的地方。

如果做一个在PC上面做小型的应用层代码,按上面说的“malloc、free使用原则”去使用malloc和free,可能不需要关注这个层面。PC资源,内存4个G算小的,硬盘500G也算小的,CPU更是强悍。可问题是,如果你是处于嵌入式环境,就没有那么乐观了。比如一个嵌入式Linux环境,Flash256M字节,内存才512M字节,有可能进程栈限制为2M,你可真得勒紧裤腰带过日子。

在进程启动时候,加入以下两行代码:

mallopt(M_MMAP_MAX, 0); // 只用brk模式,禁止malloc调用mmap来分配内存
mallopt(M_TRIM_THRESHOLD, -1); // 禁止内存紧缩

这样绝对是有好处的。

就算是服务器级别的PC资源,如果高并发、多线程、多进程,这种需要大资源和重负荷型应用,不掌握堆分配原理是不可能的。某Q公司在2015年就有1万台服务器了,如果不能优化内存问题,可能再加1万个服务器也顶不住。打住,1万个服务器你知道多少钱吗?

10 mallopt()--控制 内存分配的函数

因为这一部分是常识性的东西,所以直接摘录下来,供查询所用。在真正的工程项目,尤其是嵌入式Linux工程中,某些参数非常有用。

int mallopt(int param,int value)//控制 内存分配的函数 。
  param 的取值可以为M_CHECK_ACTION、M_MMAP_MAX、
  M_MMAP_THRESHOLD、M_MXFAST(从glibc2.3起)、
  M_PERTURB(从glibc2.4起)、M_TOP_PAD、M_TRIM_THRESHOLD。
  此处解释param取值为M_MXFAST的情况
  value是以 字节为单位的。
  比如设置M_MMAP_THRESHOLD选项可以设置启用mmap申请malloc
字节数阀值,设置-1是不启用mmap
下面这些选项可以通过mallopt()进行设置:
1.  M_MXFAST
M_MXFAST用于设置fast bins中保存的chunk的最大大小,默认值为64B,
fast bins中保存的chunk在一段时间内不会被合并,分配小对象时可以首先
查找fast bins,如果fast bins找到了所需大小的chunk,就直接返回该chunk,
大大提高小对象的分配速度,但这个值设置得过大,会导致大量内存碎片,并
且会导致ptmalloc缓存了大量空闲内存,去不能归还给操作系统,导致内存暴增。
M_MXFAST的最大值为80B,不能设置比80B更大的值,因为设置为更大的值
并不能提高分配的速度。Fast bins是为需要分配许多小对象的程序设计的,比
如需要分配许多小struct,小对象,小的string等等。
如果设置该选项为0,就会不使用fast bins。
 
2.    M_TRIM_THRESHOLD
M_TRIM_THRESHOLD用于设置mmap收缩阈值,默认值为128KB。自动收缩
只会在free时才发生,如果当前free的chunk大小加上前后能合并chunk的大小
大于64KB,并且top chunk的大小达到mmap收缩阈值,对于主分配区,调用
malloc_trim()返回一部分内存给操作系统,对于非主分配区,调用heap_trim()
返回一部分内存给操作系统,在发生内存收缩时,还是从新设置mmap分配阈
值和mmap收缩阈值。
         这个选项一般与M_MMAP_THRESHOLD选项一起使用,
         M_MMAP_THRESHOLD用于设置mmap分配阈值,对于长时间运行的
         程序,需要对这两个选项进行调优,尽量保证在ptmalloc中缓存的空闲
         chunk能够得到重用,尽量少用mmap分配临时用的内存。不停地使用
         系统调用mmap分配内存,然后很快又free掉该内存,这样是很浪费系
         统资源的,并且这样分配的内存的速度比从ptmalloc的空闲chunk中分
         配内存慢得多,由于需要页对齐导致空间利用率降低,并且操作系统调
         用mmap()分配内存是串行的,在发生缺页异常时加载新的物理页,需
         要对新的物理页做清0操作,大大影响效率。
         M_TRIM_THRESHOLD的值必须设置为页大小对齐,设置为-1会关闭内
         存收缩设置。
         注意:试图在程序开始运行时分配一块大内存,并马上释放掉,以期望
         来触发内存收缩,这是不可能的,因为该内存马上就返回给操作系统了。
 
3.    M_MMAP_THRESHOLD
    M_MMAP_THRESHOLD用于设置mmap分配阈值,默认值为128KB,ptmalloc
默认开启动态调整mmap分配阈值和mmap收缩阈值。
    当用户需要分配的内存大于mmap分配阈值,ptmalloc的malloc()函数其实相
当于mmap()的简单封装,free函数相当于munmap()的简单封装。相当于直接通过
系统调用分配内存,回收的内存就直接返回给操作系统了。因为这些大块内存不能
被ptmalloc缓存管理,不能重用,所以ptmalloc也只有在万不得已的情况下才使
用该方式分配内存。
但使用mmap分配有如下的好处:
l   Mmap的空间可以独立从系统中分配和释放的系统,对于长时间运行的程序,
申请长生命周期的大内存块就很适合有这种方式。
l   Mmap的空间不会被ptmalloc锁在缓存的chunk中,不会导致ptmalloc内存暴增的问题。
l   对有些系统的虚拟地址空间存在洞,只能用mmap()进行分配内存,sbrk()不能运行。
使用mmap分配内存的缺点:
l   该内存不能被ptmalloc回收再利用。
l   会导致更多的内存浪费,因为mmap需要按页对齐。
l   它的分配效率跟操作系统提供的mmap()函数的效率密切相关,Linux系统
强制把匿名mmap的内存物理页清0是很低效的。
所以用mmap来分配长生命周期的大内存块就是最好的选择,其他情况下都不太高效。
 
4.    M_MMAP_MAX
M_MMAP_MAX用于设置进程中用mmap分配的内存块的最大限制,默认值
为64K(cat /proc/sys/vm/max_map_count),因为有些系统用mmap分配的
内存块太多会导致系统的性能下降。
如果将M_MMAP_MAX设置为0,ptmalloc将不会使用mmap分配大块内存。
         Ptmalloc为优化锁的竞争开销,做了PER_THREAD的优化,也提供了
         两个选项,M_ARENA_TEST和M_ARENA_MAX,由于PER_THREAD的
         优化默认没有开启,这里暂不对这两个选项做介绍。
         另外,ptmalloc没有提供关闭mmap分配阈值动态调整机制的选项,
         mmap分配阈值动态调整时默认开启的,如果要关闭mmap分配阈值
         动态调整机制,可以设置M_TRIM_THRESHOLD,
         M_MMAP_THRESHOLD,M_TOP_PAD和M_MMAP_MAX中的任
         意一个。但是强烈建议不要关闭该机制,该机制保证了ptmalloc尽量
         重用缓存中的空闲内存,不用每次对相对大一些的内存使用系统调用
         mmap去分配内存。

 

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/333774.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号