你不知道的内存管理

抓主线,三个点:

  1. 虚拟内存组织
  2. 虚拟内存和物理内存的转换
  3. 物理内存组织

虚拟内存组织

平时在进程中,所谓的内存地址,其实都是虚拟地址(VA),而不是实际的物理内存地址(PA)。这样做的好处在于:

  1. 进程间隔离
  2. 方便共享资源
  3. 可以按需分配物理内存

注:在32位机器中,进程可用的地址空间是2^32, 即4GB。64位机器是2^48

在Linux内核中,每一个进程,就是一个task_struct结构。在task_struct中,有一个mm_struct结构指针,这个结构是用来管理进程的内存的。其中,又有一个mmap字段,存储了一堆vm_area_struct,vm_area_struct中的vm_start和vm_end就标识一段虚拟内存区域。由于平常进程会有这样的操作:给一个虚拟地址,然后找出这个地址所属的区间,及对应的vm_area_struct,所以使用红黑树去组织这堆vm_area_struct。当访问一个所有vm_area_struct都不存在的地址时,就会发生段错误。

注意:mm是指针,因此可以多个进程指向同一个mm_struct,多个进程使用同一块内存空间。

动态内存管理

了解进程地址空间的都知道,一个进程的地址空间通常都分成离散的若干块,每一个块都有自用的特殊用处。

其中,堆我们应该最熟悉了,在堆的vm_start和vm_end之间的存储区域,就是我们平时动态分配内存的区域。由于是动态分配的,就涉及快速分配、快速释放、内存碎片等问题。动态内存分配大致上可以分为几种:

  1. 隐式链表+边界标记
  2. 显式链表+边界标记
  3. 分离链表

隐式链表

已分配的内存块中有头部会记录这个内存块的使用情况,例如块大小和这块内存是否被使用等信息。

利用这个块大小和是否使用这两个信息,我们就可以遍历空闲链表,已达到分配内存的目的。

释放内存时,我们就使用边界标记法来快速合并空闲内存块。


显示链表

我们可以发现,在隐式链表方案中,分配内存的时间复杂度和内存块是线性关系,如果内存块很多时,那么分配内存就很慢,为了提高性能,我们可以显式的维护一条空闲链表,这样分配内存就快多了。是否时,可以LIFO,或者按地址顺序去插入到链表中,LIFO好处在于释放是常数级别的复杂度,按地址顺序插入的好处是使用首次适配时能更好的利用存储器。同样,合并的时候,使用边界标记法来快速合并。

分离链表

进一步,我们可以把不同大小的内存块,分别用不同的链表的组织,那么查空闲块的时候,就直接到相关大小的链表中找,没有就往更大的链表中找,可以避免在很多小空闲小内存块时,突然分配一块大内存要找半天。分配时会分割内存块,然后把多余的放到对应的链表中。释放时会合并,然后放到一个相应的块(lazy操作)。目前所有高性能的内存分配器都使用分离链表的模型。伙伴系统其实就是2的k次幂的分离链表。

之前提到两次边界标记法,下面简单说一下

对于隐式链表和显式链表,合并时,不知道相邻的上一块内存是否被使用,除非从头开始遍历。为了解决这个性能问题,我们除了在头部记录当前块大小,当前块是否被使用外,再加一个标志位,记录上一个相邻的内存块是否被使用;对于空闲的内存块,增加和空闲内存块头部一样的脚部(在空闲块加额外信息,而不是在已分配块中加,可以提高内存使用率)。这样合并空闲内存块时,无论上一块内存块,还是下一块内存块,都能很快速的合并了。

番外1:C++ STL内存池

申请一大块内存,然后分割成一个个固定块,组织成不同的空闲内存块链表。小内存从内存池中分配,大内存直接向系统申请。小内存释放是给回链表,大内存的释放直接给回系统。所以频繁new小于128字节的对象,看起来会像有内存泄露。

空闲内存链表分为8、16、24、32、...、128这几个固定大小的内存块链表

实现的trick:

使用union去指向一个内存块节点

union _Obj {

    union _Obj* _M_free_list_link;

    char _M_client_data[1];

}

这个结果用来指向一个内存块,当是空闲内存块时:

使用_M_free_list_link变量,即next指针,指向下一块空闲内存块

当分配出去后,使用_M_client_data,就表示这个内存块的分配出去的内存块的地址

就是说,把next指针作为内存块大小的一部分,因为只有内存块空闲时,next才有意义,分配出去时next就没意义了,这样就省了一个专门的指针空间,省内存。

番外2:malloc与free

128kb是堆,超过128kb是mmap

虚拟地址(VA)和物理地址(PA)的转换

总所周知,x86系统的MMU是段页结构,由分段部件和分页部件组成。在x86系统中,有三种地址空间:

逻辑地址、线性地址、物理地址

逻辑地址就相当于段地址,线性地址就相当于页地址。

为了简化内存管理,Linux不是一个段页式结合的系统,而是一个分页的。但是由于硬件限制,在x86系统上,Linux依然会装模作样的做一些段地址的初始化,实际上把逻辑地址直接映射成线性地址,即线性地址=逻辑地址。

然后我们再说一下Linux的分页系统

分页系统

mm_struct中,有一个PGD字段,这个字段指向的是页表的基地址。这个页表,就是用来把虚拟地址,转换成物理地址的一个表。切换进程的地址空间,其实就是把CR3寄存器的值置为该进程的页表基地址。

为了节省内存,操作系统一般会用多级页表,根据硬件的不同来确定用几级。

  • 64位系统:使用四级分页或三级分页,跟硬件有关。
  • 未开启PAE(物理地址扩展)的32位系统:只使用二级分页,页上级目录和页中间目录里的值全为0。
  • 开启PAE的32位系统:使用三级分页,这种情况下被排除在外的是页上级目录,也就是页上级目录中所有值都为0。

一图胜千言,以3级页表为例:

一个VA分成4部分,VPN1 VPN2 VPN3 VPO。翻译的过程是这样的:

  1. 根据一级页表基地址(PGD)和 VPN1 ,找到对应的页表目录项(PTE),获取二级页表基地址
  2. 根据二级页表基地址 和 VPN2 ,找到对应的页表目录项(PTE),获取三级页表基地址
  3. 根据三级页表基地址 和 VPN3 ,找到对应的页表目录项(PTE),获取物理页的地址(PPN)
  4. 根据PPN 和 VPO ,合成物理地址(PA)

由于PTE的值也是一个存在物理内存中的数据,因此也能CPU缓存也能缓存它,因此我们的翻译过程是:


传输后备缓冲器(TLB)

一个虚拟地址,要经过页表一级一级的查找,就算有CPU缓存,这样速度太慢,而且CPU缓存是通用的,很容易被其他数据淘汰掉,命中率还进一步降低。为了速度,我们希望直接能通过VPN来获取到PTE。

所以CPU中有TLB的存在,TLB相当于页表专用的高速缓存。把VA中的VPO去掉,然后去TLB那里查。

以一级页表为例,当TLB没命中时的翻译过程:


TLB命中的翻译过程:


完整的过程如下:

TLB以VPN作key来缓存,所以是和每个进程相关联的,因而,切换进程后,我们要避免上一个进程的TLB对当前进程的TLB有影响,不然就去到找到的就是错误的地址了。因此当页表树变化时,我们要刷新TLB。

刷新TLB的成本非常大,因此有很多优化策略,例如lazy刷新、尽量只刷新用户空间的TLB、系统建立软TLB等等。这里不展开。

物理内存组织

架构

一般来说,CPU的内存控制架构分两种,一致性内存访问(uniform memory access)和非一致性内存访问(non uniform memory access)

UMA将可用内存以连续方式组织起来,系统中的每个处理器访问各个内存都是同样的块。 NUMA的各个CPU都有本地内存,可支持特别快的访问,各个处理器之间通过总线连接起来。

Linux为了统一情况,使用NUMA模型,当计算机是UMA时,就用一个NUMA节点来管理整个系统的内存。

在i386机器中,页式存储管理器的硬件在CPU内部实现的,而不是使用独立的MMU,而我们知道,DMA是不经过CPU的,因此也没有经过MMU地址映射,换句话说,就是需要直接访问物理地址。DMA是外设,还有其他的各种外设,有些外设的寻址空间有限,这样就出现了分区。对于物理内存,一般来说会分成三个区域:

  • · ZONE_DMA(0-16 MB):包含 ISA/PCI 设备需要的低端物理内存区域中的内存范围。
  • ·ZONE_NORMAL(16-896 MB):由内核直接映射到高端范围的物理内存的内存范围。所有的内核操作都只能使用这个内存区域来进行,因此这是对性能至关重要的区域。
  • ZONE_HIGHMEM(896 MB 以及更高的内存):系统中内核不能映像到的其他可用内存。

注:大小根据cpu不同会有所不同,另外64位系统的是DMA、DMA32、NORMAL三个区

对于某个用户页面的请求可以首先从“普通”区域中来满足(ZONE_NORMAL);

如果失败,就从 ZONE_HIGHMEM 开始尝试; 如果这也失败了,就从 ZONE_DMA 开始尝试。

NUMA的系统架构详细点,就变成这样:

Linux将NUMA中内存访问速度一致的(按内存通道划分),称为一个节点(Node),用struct pglist_data结构表示,通常使用时,用它的typedef定义的pg_data_t。系统中的每个节点都通过pgdat_list链表pg_data_t->node_next连接起来,以NULL为接受标志。每个节点有进一步分成多个区(zone),用struct zone_struct表示,它的typedef定义为zone_t。每个区又有多个页面(page)。

节点、区、页三者的关系如下:

物理内存的分配和释放

在zone_struct中,free_area类型数组的成员free_area,用于实现伙伴系统。下标是0,表示2的0次方个连续页面,下标是n,表示2的n次方个连续页面

之前说过伙伴系统是分离链表的一个特例而已,这里就不展开讨论细节了。

从结构中我们可以看出,伙伴系统最多只能细化到一个页上,很多时候,我们用不到一页,所以在伙伴系统的基础上,有SLUB分配器(以前是SLAB,因为巨复杂,并且内存使用率不高,被替换掉了),专门用于分配小内存。

SLUB

Linux 内核经常使用许多特定的数据结构(例如task_struct),这些结构在使用前需要调用“构造函数”进行初始化。所以在 Linux 内核中,我们把用来存储数据结构的内存单元叫做对象。如果我们能在使用前就把对象初始化好,会 大大提高内存分配的速度。 1) 内核中许多对象的使用非常频繁,而且使用的数量随着系统的运行动态变化,不可预料。 2) 对象的大小一般都比较小,比如几十 Byte,如果按照对待应用程序的内存分配方式,最少一次分配 4KB,会造成内存的浪费。 3) 内核初始化对象的时间花费,甚至超过分配和释放内存本身的时间。如果我们不销毁回收的对象,而建立某种缓存机制,不必每次使用前都初始化,会大大提高内存分配的速度。 Linux 内核中用的数据结构繁多,所以内核中对象的种类也很多。每种对象的大小,使用的频度都不一样,也无法预测。内核的内存管理要动态的适应每种对象的特点,高速高效的管理内存。 Slub 分配机制的基本思想:当内核申请使用对象时,一次初始化一组对象,供内核使用;当内核释放对象时,不释放所占用内存,将该内存保持为可直接使用的初始化状态,供下次使用。Slub 机制使用了这种基本思想和其他一些思想来构建一个在空间和时间上都具有高效性的内存分配机制。

我们先说刚开始的SLUB版本,再说3.20中的SLUB版本

先说slub的基本结构,每一个kmem_cache管理某种特定大小的对象池,在kmen_cache中,有两种重要的结构

  1. kmem_cache_cpu 代表一个cpu
  2. kmem_cache_node 代表一个NUMA节点

kmem_cachec_cpu中,有个freelist,记录了当前这个cpu使用的slab中的空闲对象池。

kmem_cache_node中,有个partial,记录当前这个节点中部分满的page,page中也有个freelist,记录了这个page中的空闲对象池。

注:这里的page,指的不是一个页面,而是2^order个连续页面,order值由kmem_cache里面的oo或min来确定

分配的时候,优先从kmem_cache_cpu中分配,如果不行,就从kmem_cache_node中的partial中拿一个page来分配。分配时具体由以下几种情况:

1、kmem_cache_cpu的freelist不为空

直接返回freelist的第一个节点

2、kmem_cache_cpu的freelist为空,kmem_cache_node的partial不为空

选取一个page,把page中的freelist赋予kmem_cache_cpu中的freelist,再把page中的freelist置NULL,并从partial中去掉。

3、kmem_cache_cpu的freelist为空,keme_cache_node的partial为空

从伙伴系统中获取一批页面,放到kmem_cache_node的partial中,然后同2

释放的时候,直接把对象释放到对应的page的freelist中,然后按照page的状态执行相应的操作,例如page原本是full,变成partial了,就放到kmem_cache_node的partial中去,变成empty了,就回收到伙伴系统中。

这里面有几个值得思考的地方

  1. 为什么引入kmem_cache_cpu
  2. 为什么直接把page的freelist给kmem_cache_cpu,然后page的freelist置NULL

我们先设想完全没有kmem_cache_cpu时,分配的动作就变成:

  1. 从对应的kmem_cache_node的partial链表里面选择一个page;
  2. 从选中的page的freelist链表里面选择一个对象;

但是这个过程有个不好的地方,kmem_cache_node的partial链表是全局的、page的freelist链表也是全局的:

  1. 第一步访问partial链表的时候,由于分配后的page可能变成full,所以访问partial需要上锁;
  2. 第二步访问page->freelist链表的时候,需要取出对象,所以也需要上锁;

每次分配对象都有两个锁,效率低。

想一下解决方案,我们把分配的page从partial中拿出来,那么就可以避免lock kmem_cache_node了。问题在于我们释放对象时,这个page可能重新回到partial中,其他cpu可能会操作这个page,所以还是会lock page->freelist。因此我们引入kmem_cache_cpu及cpu级别的freelist。分配时,kmem_cache_cpu直接接管了page->freelist,就只和cpu级别的freelist打交道,之后的分配操作就和page->freelist没有关系了。那么就算page上有对象释放,重新回到partial,然后被其他处理器cache,cpu间的分配动作也是井水不犯河水,不用加锁了。

所以有了kmem_cache_cpu和cpu级别的freelist,kmem_cache_cpu的freelist不为空时,分配就完全不用加锁。kmem_cache_cpu的freelist为空时,只加一次锁。

当然,这样设计也提高了cpu缓存的命中率。

性能更进一步

我们可以发现,在分配的时候,大部分时间都不用加锁,可是释放的时候,既要操作partial,也要操作page->freelist,那么就要加两次锁了,所以释放的时候会有性能问题。

因此,在Linux kernel 3.20版本中,SLUB引入了per cpu cache for partial pages

使得SLUB的性能提高了20%,主要在这三方面的改善:

  1. 释放时不用lock kmem_cache_node
  2. 批量从partial中获取page,把分配时lock kmem_cache_node的成本分摊了
  3. 更多的从cpu->partial中获取page,减少lock kmem_cache_node

(图有点错,cpu0和cpu1才对)

加入了这个特性后,分配的变化就是在cpu->freelist和page中间,多了一个cpu->partial,若cpu->freelist为空,就从cpu->partial中获取page,若cpu->partial为空,就从node->partial中批量获取page,加到cpu->partial中。这个批量的值是保持cpu->partial中的空闲对象池多于kmem_cache结构中的cpu_partial的1/2。而这个cpu_partial的值是根据对象的大小决定的,Linux中的源码:

if (!kmem_cache_has_cpu_partial(s))

    s->cpu_partial = 0;

else if (s->size >= PAGE_SIZE)

    s->cpu_partial = 2;

else if (s->size >= 1024)

    s->cpu_partial = 6;

else if (s->size >= 256)

    s->cpu_partial = 13;

else

    s->cpu_partial = 30;

释放的变化在于释放时,如果page从full变成partial,就放到cpu->partial中。

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