Linux 内存管理

Linux将它的物理内存虚拟化。进程并不能直接在物理内存上寻址,而是由Linux内核为每个进程维护一个特殊的虚拟地址空间(virtual address space)。这个地址空间是线性的,从0开始,到某个最大值。虚拟空间由许多页组成。系统的体系结构以及机型决定了页的大小(页的大小是固定的),典型的页的大小包括4K(32位系统)和8K(64位系统)。每个页面都只有无效(invalid)和有效(valid)这两种状态,
有效页面(valid page)和一个物理页或者一些二级存储介质相关联,例如一个交换分区或者一个在硬盘上的文件。
无效页面(invalid page)没有关联,代表它没有被分配或使用。对无效页面的访问会引发一个段错误。
地址空间不需要是连续的。虽然是线性编址,但实际上中间有很多未编址的小区域。一个进程不能访问一个处在二级存储中的页,除非这个页和物理内存中的页相关联。如果一个进程尝试访问这样的页面,那么存储器管理单元(MMU)会产生一个页错误(page fault)。

虚存中的多个页面,甚至是属于不同进程的虚拟地址空间,也有可能被映射到同一个物理页面。这样允许不同的虚拟地址空间共享(share)物理内存上的数据。共享的数据可能是只读的,或者是可读可写的。当一个进程试图写某个共享的可写页时,另一种情况是MMU会截取这次写操作并产生一个异常;作为回应,内核就会透明的创造一份这个页的拷贝以供该进程进行写操作。我们将这种方法称为写时拷贝(copy-on-write)(COW)。

内核将具有某些相同特征的页组织成块(blocks),例如读写权限。这些块叫做存储器区域(memory regions),段(segments),或者映射(mappings)。典型的段包括:
1,文本段(text segment)包含着一个进程的代码,字符串,常量和一些只读的数据。在Linux中,文本段被标记为只读,并且直接从目标文件(可执行程序或是库文件)映射到内存中。
2,堆栈段(stack)包括一个进程的执行栈,随着栈的深度动态的伸长或收缩。执行栈中包括了程序的局部变量(local variables)和函数的返回值。
3,数据段(data segment),又叫堆(heap),包含着一个进程的动态存储空间。这个段是可写的,而且它的大小是可以变化的。这部分空间往往是由malloc分配的。
4,BSS段(bss segment)包含了没有被初始化的全局变量。这些变量根据不同的C标准都有特殊的值,通常来说,都是0。

动态内存分配
void malloc (size_t size);
void
calloc (size_t nr, size_t size);
void realloc (void ptr, size_t size);
void free (void *ptr);
malloc()时会得到一个size大小的内存区域,并返回一个指向这部分内存首地址的指针。这块内存区域的内容是未定义的,不要自认为全是0。失败时,malloc()返回NULL,并设置errno错误值为ENOMEM。
数组分配calloc与malloc不同的是,calloc将分配的区域全部用0进行初始化。要注意的是二进制0和和浮点0是不一样的。
调整已分配内存大小realloc成功调用realloc()将ptr指向的内存区域的大小变为size字节。它返回一个指向新空间的指针,当试图扩大内存块的时候返回的指针可能不再是ptr。因为有潜在的拷贝操作,如果size是0,效果就会跟在ptr上调用free()相同。

调用free()会释放ptr指向的内存。但ptr必须是之前调用malloc(),calloc(),或者realloc()的返回值。也就是说,你不能用free()来释放申请到的部分内存,比如说用一个指针指向一块空间中间的位置。ptr可能是NULL这个时候free()什么都不做就返回了,因此调用free()时并不需要检查ptr是否为NULL。内存泄漏和悬垂指针有两个常用的工具可以帮助你解决这些问题:Electric Fence和valgrind 。

数据的对齐(alignment)是指数据地址和由硬件确定的内存块之间的关系。一个变量的地址是它大小的倍数时,就叫做自然对齐(naturally aligned)。POSIX1003.1d提供一个叫做posix_memalign()的函数。BSD和SunOS分别提供了如下接口:valloc除了标准类型的对齐和内存分配,对齐问题还可以进行扩展。比如说,复杂的数据类型的对齐问题将会比标准类型的更复杂。

数据段的管理

堆的起始地址空间在由操作系统和执行文件大小决定的。
int brk(void addr);
void
sbrk (intptr_t increment);
因为malloc()和其它的方法更强大也易于使用,大多数程序都不会直接地使用这些接口。sbrk老版本Unix系统中函数的名字,那时堆和栈还在同一个段中。堆中动态存储器的分配由数据段的底部向上生长;栈从数据段的顶部向着堆往下生长。堆和栈的分界线叫做中断(break)或中断点(break point)。在现代系统中,数据段存在于它自己的内存映射中,我们仍用中断点来标记映射的结束地址。调用brk()会设置中断点(数据段的末端)的地址为end。在成功的时候,返回0。失败的时候,返回-1,并设置errno为ENOMEM。调用sbrk()将数据段末端增加increment字节,increment可正可负。sbrk()返回修改后的断点。所以,increment为0时得到的是现在断点的地址。

匿名存储器映射
Glibc的内存分配使用了数据段和内存映射。实现malloc()最经典方法就是将数据段分为一系列的大小为2的幂的块,返回最小的符合要求的那个块来满足请求。释放则只是简单的将这块区域标记为未使用。如果相邻的分区都是空闲的,他们会被合成一个更大的分区。如果堆的最顶端是空的,系统可以用brk()来降低断点,使堆收缩,将内存返回给系统。这个算法叫做伙伴内存分配算法(buddymemoryallocationscheme)。它的优点是高速和简单,缺点则是会产生两种类型的碎片。
1,当使用的内存块大于请求的大小时则产生内部碎片(Internal fragmentation)。
2,外部碎片是在空闲存储器合计起来够满足一个请求,但是没有一个单独的空间块可以来处理这个请求时发生的。这同样会导致内存利用不足(因为可能会分配一个更大的块)或是分配的失败(如果已经没有可选的块存在了)。
3,这个算法会使一个内存的分配“栓”住另外一个,导致glibc不能将释放的内存返回给系统。想象内存中已被分配的两个块,块A和块B。块A正好处在中断点的位置,块B刚好在A的下面,就算释放了B,在A被释放前,glibc也不能相应的调整中断点。
Glibc并不是一直在试图将空间返回给系统。通常来说,在每次释放后堆并不收缩。glibc会维护释放的内存以供之后的分配使用。只有当堆明显的大于已分配的内存时,glibc才会减小数据段的大小。

对于较大的分配,glibc并不使用堆而是创建一个匿名内存映射(anonymous memory mapping)来满足要求。匿名存储器映射和在第四章讨论的基于文件的映射很相似,只是它并不基于文件——所以称之为“匿名”。实际上,一个匿名内存映射只是一块已经用0初始化的大的内存块,以供用户使用。可以把它想成为单独为某次分配而使用的堆。因为这种映射的存储不是基于堆的,所以并不会在数据段内产生碎片。
使用匿名映射来分配内存有下列好处:
1,无需关心碎片。
2,匿名存储映射的大小的是可调整的,可以设置权限,还能像普通的映射一样接受建议。
3,每个分配存在于独立的内存映射。没有必要再去管理一个全局的堆了。

使用匿名映射与堆比起来也有两个缺点:
1,每个存储器映射都是页面大小的整数倍。可能浪费内存空间。
2,创建一个新的内存映射比从堆中返回内存的负载要大,因为使用堆几乎不涉及任何内核操作。越小的分配,这样的问题也越明显,不涉及小而频繁的请求。

根据各自的优缺点来判断,glibc的malloc()使用数据段来满足小的分配,而匿名内存映射则用来满足大的分配。两者的临界点是可调的(请参阅本章稍后的高级内存分配部分),并会随着glibc版本的不同而有所变化。目前,临界点一般是128KB:比128KB小的分配由堆实现,相应地,较大的由匿名存储器映射来实现。用下面系统调用创建和销毁系统调用:
void mmap (void start, size_t length, int prot, int flags, int fd, off_t offset);
int munmap (void *start, size_t length);

当输入的fd是-1时,创建匿名映射,在BSD系统上也可以是打开/dev/zero设备的fd。

高级存储器分配
int mallopt (int param, int value);
存储分配操作都是受内核的参数所控制和限制的,程序员可以修改这些参数。比如最大的存储器映射数量、使用匿名映射还是数据段的判断阈值、高速内存区域的大小、填充字节数。

size_t malloc_usable_size(void *ptr);
查询一块已分配内存中有多少可用字节。

int malloc_trim(size_t padding);
调用malloc_trim()成功时,强制glibc归还所有的可释放的动态内存给内核。数据段会尽可能地收缩,但是填充字节被保留下来。然后返回1。失败时,返回0。

调试内存分配
因为仅仅一个环境变量就能控制调试,你不必重新编译你的程序。例如,你可以简单的执行如下指令:
$MALLOCCHECK=1 ./rudder
如果设置为0,存储系统会忽略所有错误。如果它被设为1了,信息会被输出到标准错误输出stderr。如果设置为2,进程会立即通过abort()终止。

struct mallinfo mallinfo (void);
C标准库获得关于动态存储分配系统的统计数据,包括空闲块的个数,匿名映射的大小,可用的块大小等等。

基于栈的分配
void * alloca (size_t size);
在一个栈中实现动态内存分配,不必释放分配到的内存,失败就表明出现的栈溢出。但需要注意:
1,如果要让代码具有可移植性,你要避免使用alloca()。
2,不能使用由alloca()得到的内存来作为一个函数调用的参数,因为分配到的内存块会被当做参数保存在函数的栈中。
在Linux系统上,alloca()却是一个非常好用但没有被人们认识到的工具。它表现的异常出色(在各种架构下,通过alloca()进行内存分配就和增加栈指针一样简单),比malloc()的性能要好很多。对于Linux下较小的内存分配,alloca()能收获让人激动的性能。

alloca()常见的用法是用来临时复制一个字符串,因为这种需求非常多以及alloca()实现的高效,Linux系统专门提供了strdup()来将一个给定的字符串复制到栈中。

C99引进了变长数组(VLAs),变长数组的长度是在运行时决定的,而不是在编译的时候。alloca()和变长数组的主要区别在于通过前者获得的内存在函数执行过程中始终存在,而通过后者获得的内存在出了作用域后便释放了。这样的方式有好有坏。在for循环中,我们希望每次循环都能释放空间以在没有任何副作用的情况下减小内存的开销(我们不会希望有多余的内存始终被占用着)。然而,如果出于某种原因我们希望这块空间能保留到下一轮的循环中,那么使用alloca()显然是更加合理的。

选择一个合适的内存分配机制
分配方式 优点 缺点
malloc() 简单,方便,最常用 返回的内存为用零初始化
calloc() 使数组分配变得容易,用0初始化了内存 在分配非数组空间时显得较复杂
realloc() 调整已分配的空间大小 只能用来调整已分配空间的大小
brk()和sbrk() 允许对堆进行深入控制 对大多数使用者来说过于底层
匿名内存映射 使用简单,可共享,允许开发者调整保护等级并提供建议,适合大空间的分配 不适合小分配。最优时malloc()会自动使用匿名内存映射
posix_memalign() 分配的内存按照任何合理的大小进行对齐 相对较新,因此可移植性是一个问题;对于对齐的要求不是很迫切的时候,则没有必要使用
memalign()和valloc() 相比posix_memalign()在其它的Unix系统上更常见 不是POSIX标准,对对齐的控制能力不如posix_memalign()
alloca() 最快的分配方式,不需要知道确切的大小,对于小的分配非常适合 不能返回错误信息,不适合大分配,在一些Unix系统上表现不好
变长数组 与alloca()类似,但在退出此层循环时释放空间,而不是函数返回时 只能用来分配数组,在一些情况下alloca()的释放方式更加适用,在其它Unix系统中没有alloca()常见

存储器操作
void memset (void s, int c , size_t n);
int memcmp (const void s1, const void s 2 , size_t n);
void memmove (void dst, const void src, size_t n);
void
memcpy (void dst, const void src, size_t n);
void memchr (const void s, int c, size_t n);
void memfrob (void s, size_t n);
C语言提供了很多函数进行内存操作。这些函数的功能和字符串操作函数(如strcmp()以及strcpy())类似,但是他们处理的对象是用户提供的内存区域而不是以NULL结尾的字符串。要注意这些函数都不会返回错误信息。因此防范错误是程序员的责任,如果传递错误的内存区域作参数的话,你将毫无疑问的得到段错误。

内存锁定
Linux实现了请求页面调度,页面调度是说在需要时将页面从硬盘交换进来,当不再需要时再交换出去。这使得系统中进程的虚拟地址空间与实际的物理内存大小没有直接的关系,同时硬盘上的交换空间提供一个拥有近乎无限物理内存的假象,在下面两种情况下,应用程序可能希望影响系统的页面调度:
1,确定性(Determinism) 时间约束严格的应用程序需要自己来决定页的调度行为。如果一些内存操作引起了页错误——这会导致昂贵的磁盘操作——应用程序则可能会超出要求的运行时间。如果能确保需要的页面总在内存中且从不被交换进磁盘,应用程序就能保证内存操作不会导致页错误,提供一致的,可确定的程序行为,从而提供了效能。
2,安全性(Security) 如果内存中含有私人信息,这些信息可能最终被页面调度以不加密的方式储存到硬盘上。例如,如果一个用户的私钥正常情况下是以加密的方式保存在磁盘上的,一个在内存中未加密的密钥备份最后可能保存在了交换文件中。在一个高度注重安全性的环境中,这样做可能是不可接受。这样的应用程序可以请求将密钥一直保留在物理内存上。

int mlock (const void addr, size_t len);
int mlockall (int flags);
int munlock (const void
addr, size_t len);
int munlockall (void);
int mincore (void start, size_t length, unsigned char vec);
因为内存的锁定能影响一个系统的整体性能-实际上,如果太多的页面被锁定,内存分配会失败——Linux对于一个进程能锁定的页面数进行了限制。拥有CAP_IPC_LOCK权限的进程能锁定任意多的页面。没有这个权限的进程只能锁定RLIMIT_MEMLOCK个字节。

投机性存储分配策略
Linux使用投机分配策略。当一个进程向内核请求额外的内存——如扩大它的数据段,或者创建一个新的存储器映射——内核作出了分配承诺但实际上并没有分给进程任何的物理存储。仅当进程对新“分配到”的内存区域作写操作的时候,内核才履行承诺,分配一块物理内存。内核逐页完成上述工作,并在需要时进行请求页面调度和写时复制。这么做的优点:
1,延缓内存分配允许内核将大部分工作推迟到最后一刻(当确实需要进行分配时)。
2,由于请求是根据需求逐页的分配,只有真正需要物理内存的时候才会消耗物理存储。
3,分配到的虚拟内存可能比实际的物理内存甚至比可用的交换空间多得多。这个特征叫做超量使用(overcommitment)。

超量使用的功能可以通过修改配置文件 /proc/sys/vm/overcommit_memory来关闭。如果设置为2,则是使用严格审计(strict accounting)策略,将虚拟内存限定在物理内存的一定比例之内。默认是50,因为物理内存还需要包含内核、页表、系统保留页,锁定页等。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章