由于执行系统调用需要进入内核态,运行态的切换会耗费不少时间。为了解决这个问题,人们倾向于使用系统调用来分配大块内存,然后再把这块内存分割成更小的块,以方便程序员使用,这样可以提升分配的效率。而这个任务是由glibc来承担的。
glibc 是C语言的运行时库,C语言中常用的函数,如printf,scanf,memcpy和strcat等等,它们的实现都在glibc.so 中。通过后缀名你可能也猜到了,glibc是一种动态链接库。
熟悉一下malloc 和 free 的用法,如下所示:
#include
#include
int main() {
void *p = malloc(16);
printf("%p\n", p);
free(p);
return 0;
}
上述代码中应用程序通过malloc函数申请了一块内存,并把这块内存的起始地址打印了出来,然后再通过free函数释放这块内存。
malloc 实现的基本原理是先向操作系统申请一块比较大的内存,然后再通过各种优化手段让内存分配的效率最大化。在glibc的实现里,malloc函数在向操作系统申请堆内存时,会使用mmap,以4K的整数倍一次申请多个页。这样的话,mmap的区域就会以页对齐,页与页之间的排列非常整齐,避免出现内存碎片。
从这个角度看,glibc的malloc 方法像批发商,它从供应商OS哪里一次批发很大的内存,然后以零销的方式一点点分配出去,而且它不光复杂销售,还负责售后(分配到的内存可以使用free退货);
内存的精细化管理,考虑2个因素:一是分配和回收的效率,二是内存区域的有效利用率,内存区域的有效利用率又包含两个方面,一个方面是每一小块内存。对小块内存进行精细化管理,最常用的数据结构就是立案链表。为了能够方便的进行分配和回收,人们把空闲区域记录到链表里,这就是空闲链表(free list)>
空闲链表里的节点主要是为了记录内存的开始位置和长度,如下图所示:
图中展示了一个总长度为100的内存区域,已经分割成16,16,20,16,16,16六个小的内存块。其中着色部分,也就是第一,第三和第5块是已经分配出去的,正在使用的内存,而白色区域是没有被分配的内存。图中上半部分代表空闲链表,每一块未分配的内存都会由一个空闲链表的节点进行管理。记录了这块空闲内存区域的起始位置和长度。
当分配内存的请求到达以后,我们就通过遍历free list 来查找可用的空闲内存区域,在找到合适的空闲内存区域后,就将这一块区域从链表中摘下俩。比如要请求的大小是m,就将这个结点从链表中取下,把起始位置向后移动m,大小也相应的减小m.将修改后的结点重新挂到链表上。
在释放的时候,将这个区域按照起始的排序放回到链表里,并且检查它的前后是否有空闲区域,如果有就合并成一个更大的空闲区。
这种算法比较直接,我们称其为简单算法。
举个例子。开始的时候假设内存空间如下:
然后有下面一段代码:
void test(){
void* p1 = malloc(16);
void* p2 = malloc(16);
void* p3 = malloc(20);
free(p2);
void* p4 = malloc(16);
void* p5 = malloc(16);
free(p4);
}
执行完上面代码后,内存划分就会和第一张图一样了。
如果此时,又到了一个内存分配请求,要申请一个大小为20的内存区域,虽然所有空闲区域的大小之和是48,是超过20的,但是由于这3块空闲区域并不连续,所以,我们已经无法从这100字节的内存中再分配出一块20字节的内存区域了,相对于这次请求,这三块16字节的空闲区域就是内存碎片。所以这种算法会参数内存碎片每一次分配内存时,我们都需要遍历free list,最差的情况下的时间复杂度是O(n).如果是多线程同时分配的话,free list 会被多线程并发访问,为了保护它,就必须使用各种同步机制,比如锁或者无锁的concurrent linked list 等。可见上述算法的第二个缺陷:分配效率一般,且多线程并发场景下性能还会恶化
为了改进上面2个问题,人们想了很多方法。其中一种方案是直接怼简单算法进行优化。简单算法中找到第一个可用的区域就返回,这个策略被称为First Fit,优化的具体做法是把它改成最佳匹配,改造后它要找到能满足条件的最小的空闲区域才返回。
从直观上说这种分配策略能尽可能的保留大块内存,避免它被快速地分割成小块内存,这就能更好的对抗内存碎片。但是这种策略要遍历整个链表时间复杂度反而更差。
还有一种改进方案,叫分桶式管理,这种改进是一种相对均衡的做法,在对抗内存碎片和分配释放的时间复杂度两个方向都有改善。
分桶式内存管理采用了多个链表,对于单个链表,它内部的所有节点所对应的内存区域的大小是相同的。换句话说,相同大小的区域会挂载到同一个链表上。最常见的方式是以4字节为最小单位,把所有4字节的区域挂到同一个链表上,再把8字节的区域挂到一起,然后是16字节,32字节,这样以2次幂向上增长。采用了新的数据结构后,分配和回收算法也相应地发生变化。
首先,分配的时候,我们只要找到能满足这一次分配请求的最小区域,然后去相应的连表里把整块区域都取下来。比如,分配一个7字节的内存块时,我们就可以从8字节大小的空闲链表里直接去除链表头上的那块区域,分配给应用程序。由于从链表头上删除元素的时间复杂度是O(1),所以分配内存的效率就大大提高了。
由于整块内存被提前分割成了整齐的小块,所以整个区域里不存在块与块之间内存碎片。但是还是会造成内部碎片,比如刚才分配的7字节,但是不得不给它分配8字节的内存块。
内部碎片的问题是利用率没有达到100%,最差的情况下,可能只有50%。但是内部碎片随着这一块区域的释放就消失了,所以不会因为长时间运行而累计成严重的问题。释放时,只需要把要释放的内存直接挂载到相应的链表里就行了,这个速度和分配是一样的,效率非常高。
**分桶式的内存管理比简单算法无论是在算法效率方面,还是在碎片控制方面都有很大的提升。**但它的缺陷也很明显:区域内部的使用率不够高和动态扩展能力不够好。比如,4字节的区域提前消耗完了,但8字节的空闲区域还有很多,此时就会面临两难选择,如果至二级分配8字节的区域,则区域内部浪费就比较多,如果不分配,则明明还有空闲区域,却无法成功分配。为了解决上述问题,人们在分桶的基础上继续改进,让内存可以根据需求动态的决定小的内存区域和打的内存区域的比例。这种设计的典型就是伙伴系统。
正如上面例子中所讲的,当系统中还有很多8字节的空闲块,而4字节的空闲块已经耗尽,这时再有一个4字节的请求,则会出现malloc失败的情况。为了避免分配失败,我们还可以考虑再将大块的内存做一次拆分。如果8字节,16字节的内存都没了,就会追溯到32字节内存。然后32字节内存 不会被分配出去,因为这样的话会有很大的浪费,先把32字节分成2个16字节,把后边一个挂到16字节的free list中。然后再将前面一半的16字节继续拆分成2个8字节的,把后一半8字节的再挂到8字节的free list 上。然后前面的一般8字节继续拆分为2个4字节。这种不断把一块内存分割成更小的2块内存的做法,就是伙伴系统,这两块更小的内存就叫做伙伴。
mmap 的功能十分强大,这个强大的能力依靠的是页中断机制。页中断和普通的中断一样,它的中断服务程序入口也在IDT中,但它是由MMU产生的硬件中断。页中断有两类重要的类型:写保护中断和缺页中断。正是这两类中断在整个系统的后台默默地工作着,才使得内存系统正常工作。
大多数时候,我们即使不知道它们的存在,程序也能正常运行。但是有时候,程序写得不好就会找出中断频繁发生,从而带来巨大的性能下降。这种情况,我们第一时间就应该想到统计页中断。因为除了页中断本身会带来性能下降之外,统计页中断也可以反推程序的运行特点,从而进一步分析程序瓶颈点。
在Linux系统上,页中断服务程序的名称是do_page_fault.当中断发生以后,CPU会自动地在栈里存放一个错误码,来区分页中断的类型,还会把发生页中断的虚拟地址放到CR3寄存器,这样,中断服务程序就可以清楚地知道是什么原因导致的中断。 页中断来源和动作如下:
父进程和子进程不仅可以访问公有的变量,还可以各自修改这个变量,并且这个修改对方都看不见。这其实是fork的一种写时复制机制,这里面起关键作用的就是写保护中断。
实际上,操作系统为每个进程提供了一个进程管理的结构,在偏理论的书籍里一般称其为进程控制块(Process Control Block,PCB).这个结构体里面记录了进程的页表基址,打开文件列表,信号,时间片,调度参数和线性空间已经分配的内存区域等等数据。
其中,描述线性空间已分配的内存区域的结构对于内存管理很重要。在Linux源码中,负责这个功能的结构是Vm_area_struct.
在操作系统内核里,fork的第一个动作是把PCB复制一份,但类似于物理页等进程资源不会被复制。这样的话,父进程与子进程的代码段,数据段,怼和栈是相同的,如果父进程在fork子进程之前创建了一个变量,打开了一个文件,那么父子进程都能看到这个变量和文件。
fork 的第二个动作是复制页表和PCB中的vma数组,并把所有当前正常状态的数据段,堆和栈空间的虚拟内存页,设置为不可写,然后把已经映射的物理页面的引用计数加1.这一步只需要复制页表和修改PTE中的写权限未就可以了,并不会真的为子进程的所有内存空间分配物理页面,修改映射,所以她的效率非常高。这时,父子进程的页表的情况如下图:
上图中,物理页括号中的数字代表该页被多少个进程所引用。Linux中用于管理吴立业面,和维护物理页计数的结构是mem_map和page struct.
这两个动作执行完后,fork调用就结束了。此时,由于有父进程和子进程两个PCB,操作系统就会把两个进程都加入到调度队列中。当父进程得到执行,它的IP寄存器还是指向fork调用中,所以它会从这个调用中返回,只不过返回值是子进程的PID。当子进程得到执行时,它的IP寄存器也是停在fork调用中,它从这个调用中返回,返回值是0。
接下来,写保护中断要发挥作用了,不管是父进程还是子进程,它们接下来都有可能发生写操作,但我们知道在fork的第二步操作中,已经将原来可写的地方都变成不可写了,这时必然会发生写保护中断。
于是内核会通过do_wp_page 来处理这次中断,首先系统会判断发生中断的虚拟地址对应的物理地址的引用计数,如果大于1,就说明现在存在多个进程共享这一个物理页面,那么它就需要为发生中断的进程再分配一个物理页面,把老的页面内容copy进这个新的物理页,最后把发生中断的虚拟地址映射到新的物理页,这就完成了一次写时复制。
fork之后如果要执行新的程序,就需要执行execve这个系统调用,它的主要作用是加载可执行程序并运行。
未映射页面如何自动变成正常页面的。execve的执行步骤如下: