Linux写时复制
转自https://segmentfault.com/a/1190000039869422
在Linux系统中,调用fork
系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制——这就是写时复制机制。
虚拟内存与物理内存
进程的内存可以分成虚拟内存和物理内存。
- 物理内存:就是电脑安装的内存条,如果电脑安装了2GB的内存条,那么系统就用于0~2GB的物理内存空间。
- 虚拟内存:虚拟内存是使用软件虚拟的,在32位操作系统中,每个进程都独占4GB的虚拟内存空间。
应用程序使用的是虚拟内存,比如C语言取地址符&
所得到的地址就是虚拟内存地址。而虚拟内存地址需要映射到物理内存地址才能使用,如果使用没有映射的虚拟内存地址,将会导致缺页异常。
虚拟内存地址映射到物理内存地址如下图所示:
写时复制原理
虚拟内存需要与物理内存进行映射才能使用,如果不同进程的虚拟内存地址映射到相同的物理内存地址,那么就实现了共享内存的机制。
由于进程A的虚拟内存M与进程B的虚拟内存M‘映射到相同的物理内存G,所以当修改进程A虚拟内存M的数据时,进程B虚拟内存M’也会跟着改变。
写时复制原理如下:
- 创建子进程时,将父进程的虚拟内存与物理内存映射关系复制到子进程中,并将内存设置为只读(设置为只读为了当对内存进行写操作时出发缺页异常)。
- 当子进程或父进程对内存数据进行修改时,便会触发写时复制机制:将原来的内存页复制一份新的,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写。
写时复制过程如下图所示:
当创建子进程时,父子进程指向相同的物理内存,而不是将父进程所占用的物理内存复制一份。这样做的好处有两个。
- 加速创建子进程的速度。
- 减少进程对物理内存的使用。
如上图所示,当父进程调用fork
创建子进程时,父进程的虚拟内存页M
与子进程的虚拟内存页M
映射到相同的物理内存页G,并且把父进程与子进程的虚拟内存页M都设置为只读(因为设置为只读后,对内存页进行写操作时,将会发生缺页异常,从而内核可以在缺页异常处理函数中进行物理内存页的复制)。
当子进程对虚拟内存页M进行写操作,便会触发缺页异常(因为已经将虚拟内存页M设置为只读)。在缺页异常处理函数中,对物理内存页G进行复制一份新的物理内存页G‘,并且将子进程的虚拟内存页M映射到物理内存页G’,同时将父子进程的虚拟内存页M设置为可读写。
伙伴算法
- 将空闲内存分为 m m m个组,第一组存储 2 0 2^0 20个单位的内存块,第二组存储 2 1 2^1 21的内存块,第三个组存储 2 2 2^2 22的内存块,以此类推,直到 m m m个组。
- 每个组是一个链表,用来存储同样大小的内存块。
- 如果没有剩余内存块的话,向上查找,然后再将该内存块分割,并将剩余的块放到数组中去。
举个例子,如果我们需要的是5个单位大小的内存块,先向上取整为2的幂次方,最近的也就是8,所以查看第四组,如果这个链表为空的话,也就是说如果这个链表没有空闲内存的话,就继续向上找,定位到第五组,如果这个链表不为空,那么取出这个空闲内存块,然后分割出大小为8个单位块的内存供用户使用,然后把剩余的8个内存块放到第四组的链表中。
伙伴算法内存块释放的过程:
- 释放内存块。
- 查看旁边的内存块是否是空闲的。
- 如果旁边的内存块是空闲的,则将两个内存块合并并返回到步骤2,直到到达上限(释放所有内存),或者直到遇到非空闲邻居块。
伙伴算法几乎没有外部碎片,并且释放内存很快,基本上是 l o g 2 ( N ) log_2(N) log2(N)。通常,伙伴算法采用二叉树来实现,以表示已使用或未使用的拆分内存块,每个块的伙伴可以通过块地址和块大小的异或找到。
但是仍然存在内部碎片的问题——内存浪费。因为请求的内存比小块大一点,但是比大块小很多。由于伙伴内存分配技术的工作方式,请求66KB内存的程序将会被分配128KB,这导致浪费62KB。这个问题可以通过slab
分配来解决。
Slab allocation
slab
分配是一种内存管理机制,用于对象的高效内存分配。它减少了由内存分配和释放引起的碎片。该技术用于保留包含特定类型对象的已分配内存,以便在后续分配相同类型的对象时重用。它类似于对象池,但仅用于内存,不适用于其他资源。
slab
分配的主要动机是初始化和销毁内核数据对象的成本可以超过为它们分配内存的成本。由于内核经常创建和删除对象,初始化的开销成本会导致性能显著下降。对象缓存导致初始化对象状态的函数的调用频率降低:当一个slab
分配的对象在使用后被释放时,slab
分配系统通常将其缓存,而不是执行销毁动作,这样以备下次需要该对象时重新使用,从而避免构造和初始化对象的工作。
使用slab
分配,特定类型大小的数据对象的缓存具有许多预先分配的内存"slabs";每个slab
中都有适合对象的固定大小的内存块。slab
分配器会跟踪这些块,因此当它收到为某种类型的数据对象分配内存的请求时,通常它们可以从现有slab
中使用空槽来满足请求。当分配器被要求释放对象的内存时,它只是将插槽放入slab
的空闲插槽列表中。下一次创建相同类型对象(或分配相同大小的内存)的调用将返回该内存槽并将其从空闲槽列表中删除。这个过程消除了寻找合适内存空间的需要,大大减轻了内存碎片。在这种情况下,slab
是包含预先分配的内存块的内存中的一个或多个连续页面。
理解slab
分配算法需要定义和解释一些术语
- Cache
- slab:
slab
代表一块连续的内存,通常由几个物理上连续的页面组成。slab
是与包含缓存的特定类型的对象的实际容器。
当程序设置缓存时,它会为与该缓存关联的slab
分配一些对象。
slab
有以下几种状态
- empty:空的
slab
,或者没有对象被分配 - full:完全分配的
slab
- partial:部分分配的
slab
最初,系统将每个slab
标记为空,当进程调用新的内核对象时,系统会尝试在该类型对象的缓存中的部分slab
上位该对象找到一个空闲的位置。如果不存在这样的位置,系统会从连续的物理页面中分配一个新的slab
,并将其分配给缓存。新对象从这个slab
中分配,并且它被标记为partial
。
分配发生的很快,因为系统预先构建了对象并很容易地从一个slab
中分配它们。
slab
是缓存可以增长或缩小的数量。它表示从机器分配给缓存的一次内存,其大小通常是页面大小的倍数。一个slab
必须包含一个空闲缓冲区列表以及一个已经分配的缓冲区列表。