深入Linux内核架构--内存管理设计介绍

前言

在互联网时代,大部分的应用程序基本都是IO密集型,而IO密集型的程序运行效率的关键在于内存管理,因此充分理解操作系统中内存管理是一个优秀程序员的必备知识。Linux是目前互联网服务器主流操作系统,基于Linux来具体化的分析优秀内存管理是如何设计的,不仅可以帮助我们更好的设计系统,也能让我们对我们写的程序是如何在Linux中运行的有个比较全面的理解。

操作系统简介

现代计算机基础架构起源并发展于冯·诺依曼体系结构,其核心架构抽象为:计算器,控制器,存储器,输入设备,输出设备五大基础组件,其实这里没有提到的另外一个组件就是总线,贯穿各个组件之间,是计算机系统中的血管。对应于计算机CPU,内存,硬盘,鼠标,键盘,显示器等等。

操作系统的本质就是管理计算机架构中的各个组件,将各个组件融汇贯通,形成一个整体,让计算机成为一个完全可用的独立设备。优秀的设计模式就是要进行很好的抽象,划分出职责独立的慕课,所以操作系统抽象了很多概念及接口以便于更加清晰明确的管理好各个设备,也就有了所谓的进程管理,内存管理,文件系统,设备管理等等。在Linux操作系统中每个模块的设计都可以说的上非常精妙,是我们值得我们学习的优秀设计。

操作系统设计,往往会有这么一些需要考虑的地方:吞吐率,交互性,安全性,健壮性/稳定性,灵活性。 吞吐率是指一定时间内能处理的指令数;交互性主要包括响应延时,交互模式等;安全性是指对于计算机中的资源(内存,CPU,磁盘等等,一般称为资源)管理要保证一些安全策略,比如不能被恶意篡改资源内容,划分资源安全等级等;健壮性是指要能处理各种乱七八糟的情况,比如突然断电,用户瞎输入指令等等依然能够运行稳定或者能够不要让系统崩坏而无法继续使用;灵活性则是指能够方便的满足用户的一些额外需求,比如能灵活的支持各种新的外接设备。

吞吐率,交互性,灵活性的重要性我们暂且不做讨论,总所周知,Linux是最安全的操作系统之一。这里其实主要是想要讲一下linux的健壮性,健壮性是操作系统的根基,一切抛开稳定之外的性能提升,资源优化都是耍流氓,可是如何设计一个优雅的模式来保证操作系统的健壮性来处理各种意想不到的外界干扰?

为了很好的设计健壮稳定的系统,Linux引入核心态用户态的状态设计概念,所有危险的动作只能在核心态下执行,而核心态的指令是由专业人员编写的,他们都是大神熟悉与硬件打交道的各个细节。有了这一层约定之后,就大大提升了Linux的稳定性,只要核心态的代码写的没有bug,操作系统就不会崩溃。那么什么是危险的动作?那就是一切与硬件打交道的操作,因为只要不把硬件设备里面的数据搞坏了,那操作系统都能自己恢复,但是硬件里面的数据被弄出问题来了,那操作系统可能就一脸懵逼,免疫系统瘫痪,需要多喝热水,重启试试了。看到这里有人会说:”JJYY说这么多,和内存管理有毛的关系?“ 其实就是骚为科普一下Linux核心态和用户态 :)

内存管理

内存管理划分

既然知道操作系统对所有硬件资源的操作都会区分核心态用户态,那么内存管理作为需要和内存设备打交道的模块自然而然也就划分成了两大块:虚拟内存管理(用户态)及物理内存管理(核心态),随之将地址空间划分为虚拟地址空间物理地址空间

对于不熟悉LInux内核的同学或者只学过操作系统理论的同学,应该对虚拟地址空间比较熟悉,但是对于物理内存管理应该比较陌生,因为学校课程里面基本上讲的那些知识都是介绍的虚拟地址空间,所以让人误解为虚拟地址空间就是内存管理的全部,我反正是有很长一段时间是这么认为的,直到我闭关修炼了一年 :)。

重要概念

虚拟内存与物理内存

虚拟内存是指用户空间分配或者回收的内存块,是一个逻辑概念。
物理内存是指内核实际管理的硬件设备中的实际内存,是一个实体概念。

页与页帧

页是Linux用户空间下,内存管理模式中的最小单位,是一个逻辑概念。
页帧是物理内存中的一块内存大小,一般是4KB,是一个实体概念。

用户只能看到以及操作逻辑层面的概念。

虚拟地址空间

虚拟地址空间是一个很基础的概念,其描述了一个独立的Linux进程地址空间,从而规划出了进程运行过程中的内存管理模式,以一种通用的逻辑的视图来统一不同进程的内存分配方式。祭出一张烂大街的Linux进程虚拟地址空间划分图:(这是经典32位操作系统的地址结构,虽然现在基本都是64位系统了,但是除了数值不一样,整体结构是基本一样的,所以后续的分析都是基于32位系统的描述)

memory address

其实从这张图就可以看出核心态用户态的划分,Linux内核一开始就将地址空间划出了界线,表示不愿跟用户空间的这些渣渣程序员们为伍,要有自己独立的隐私空间,防止被他们坑,成为这个混沌的操作系统中最后一片净土。

虚拟地址空间: 内核空间(kernel Space)

首先要指出的是,所有用户进程共享的都是同一个内核内存,也就是说虽然每个用户进程的虚拟地址空间看上去都有自己的Kernel Space,其实这些用户进程的kernel Space都是一样一样的。而这些kernel Space就承担着所有与硬件打交道的工作,诸如内存管理,进程调度,磁盘管理等等。

那么用户进程的虚拟地址空间的kernel Space是如何做到共享的呢?那么答案就是直接映射:物理地址空间和虚拟地址空间整块内存直接映射(不借助外部设备,通过虚拟地址就能计算物理地址),而不是我们大学课程中说的什么段式,页式,段页式之内的非连续分配需要借助MMU才能计算物理地址。有了直接映射后,所有用户进程的内核空间就都映射到同一个物理地址完成共享。

内核空间里面住着一些什么妖魔鬼怪?其实存储着各个管理进程的指令及数据,指令包括:核心调度器,内存管理,文件管理,数据包括:文件目录项缓存,打开文件inode缓存等等。

内核地址空间的划分

Kernel Space

内核空间又划分为两个部分:normal和high,normal是内核分配内存的主体,大小为896M,这块地址的分配是完全与物理地址直接一一映射的,其起始16M地址存储着内核BIOS,存储着内核指令和数据,这个部分的分配策略比较简单高效,一般来说就是采用大小最合适分配策略(从内存区域找一块大小合适的内存分配),但是这样会产生大量的碎片,high是为了处理一些碎片情况留的口子,因为这一块的内存可以不连续分配,当内核normal中没有一整块可供连续分配的内存,那么就只能在high中通过一些机制拼凑不连续的内存页来分配一整块内存,这些机制包括vmalloc持久映射以及固定映射。其中vmalloc不知道物理地址,持久映射和固定映射需要指定物理地址,这个比较偏底层,不做过多介绍。

虚拟地址空间: 用户空间(user Space)

在上图中,可以看到32位4GB虚拟地址空间的低3G是分配给用户空间的,而虚拟地址又主要划分成这么三大块:程序初始化段,运行时段以及环境变量段。
程序初始化段是从持久化存储介质中直接映射到内存的段,这一部分的地址在进程运行过程中不会变,除了里面的数据有可能变化,但是地址中的相对结构已经固定。包括:代码段,数据段,未初始化的数据段等。
运行时段是在进程运行过程中,随着用户交互的输入不同而会变化的数据区域,包括:栈区,堆区以及内存映射区。这一部分是虚拟内存管理的核心区域,后面会重点介绍。
环境变量区会记录一些进程启动时设置的系统环境变量以及启动命令行传入的参数等。

虚拟内存管理

学过操作系统的同学对于虚拟内存管理都不会陌生,所以将虚拟内存管理的介绍放在前面,可能更容易被理解,而且很多同学对物理内存的管理也并不关心。

虚拟地址空间

对于栈的概念,应该都不陌生,该部分地址从上往下增长,存储的是深度函数指令,但需要结合进程和线程来重点提一下的是,虚拟地址空间中的栈地址只描述顶级父进程的栈空间,而子线程的栈空间是维护在mmap区域的。

堆是用户空间分配内存的主要区域,该部分地址从下往上增长。对于堆区域的操作主要包括两个操作:1. brk伸展堆区域;2. do_munmap收缩堆区域。brk操作会申请一块虚拟内存区域
当用户申请指定大小的内存后,如果堆内没有可用的连续可分配虚拟地址则会调用brk将对顶地址上移,直至有可用的内存块使用,如果堆顶的虚拟内存区域没被使用时就会调用do_munmap收缩堆顶地址。

mmap区域

mmap区域主要维护两个类型的内存映射:1. 文件映射; 2. 线程栈。而文件映射其实就是我们常常看到的cached:

[test@host ~]$ free -m
             total       used       free     shared    buffers     cached
Mem:         31706      15522      16183          0        222       4670
-/+ buffers/cache:      10628      21077
Swap:            0          0          0

经过前面的介绍,应该知道了虚拟内存和物理内存的基本概念,那么虚拟内存和物理内存是如何关联起来呢?当用户向操作系统申请1MB内存时,操作系统是怎么真正的从硬件设备分配指定大小的内存?完成这个复杂工作的模块叫做虚拟内存管理,其中主要包括两块:1. 管理虚拟地址空间分配:管理所有被分配的连续的虚拟地址;2.管理虚拟地址到物理地址的映射。其中模块1就叫做内存映射;模块2存储物理内存到虚拟内存的映射关系的实体叫做页表。

内存映射

内存映射的函数原型其实就是mmap,如下图:


memory mapping

内存映射就是将虚拟内存地址划分成一个一个的区域这个区域的数据结构叫做vm_area_struct,其中存储着一些元信息,最重要的就是存储了一个区域的首尾地址,并将所有的vm_area_struct由一个红黑树管理起来,这样当访问一个虚拟地址时就能比较高效的判断这个虚拟地址是否是合法的(已经被映射管理)。

匿名映射和有名映射
[root@host ~]# cat /proc/1/maps
563d2e990000-563d2e9b3000 r-xp 00000000 103:04 262815                    /sbin/init
563d2ebb2000-563d2ebb4000 r--p 00022000 103:04 262815                    /sbin/init
563d2ebb4000-563d2ebb5000 rw-p 00024000 103:04 262815                    /sbin/init
563d30287000-563d302e6000 rw-p 00000000 00:00 0                          [heap]
7f9f8cddc000-7f9f8cfdb000 ---p 00017000 103:04 2335                      /lib64/libpthread-2.17.so
7f9f8cfdb000-7f9f8cfdc000 r--p 00016000 103:04 2335                      /lib64/libpthread-2.17.so
7f9f8cfdc000-7f9f8cfdd000 rw-p 00017000 103:04 2335                      /lib64/libpthread-2.17.so
7f9f8cfdd000-7f9f8cfe1000 rw-p 00000000 00:00 0 
7f9f8cfe1000-7f9f8d1a3000 r-xp 00000000 103:04 2309                      /lib64/libc-2.17.so
7f9f8d1a3000-7f9f8d3a3000 ---p 001c2000 103:04 2309                      /lib64/libc-2.17.so
7f9f8da1a000-7f9f8dc19000 ---p 00009000 103:04 4333                      /lib64/libnih-dbus.so.1.0.0
7f9f8dc19000-7f9f8dc1a000 r--p 00008000 103:04 4333                      /lib64/libnih-dbus.so.1.0.0
7f9f8de32000-7f9f8de33000 r--p 00017000 103:04 4335                      /lib64/libnih.so.1.0.0
7f9f8de33000-7f9f8de34000 rw-p 00018000 103:04 4335                      /lib64/libnih.so.1.0.0
7f9f8de34000-7f9f8de56000 r-xp 00000000 103:04 2264                      /lib64/ld-2.17.so
7f9f8e047000-7f9f8e04c000 rw-p 00000000 00:00 0 
7f9f8e054000-7f9f8e055000 rw-p 00000000 00:00 0 
7f9f8e055000-7f9f8e056000 r--p 00021000 103:04 2264                      /lib64/ld-2.17.so
7f9f8e056000-7f9f8e057000 rw-p 00022000 103:04 2264                      /lib64/ld-2.17.so
7f9f8e057000-7f9f8e058000 rw-p 00000000 00:00 0 
7ffecc776000-7ffecc797000 rw-p 00000000 00:00 0                          [stack]
7ffecc7d9000-7ffecc7dc000 r--p 00000000 00:00 0                          [vvar]
7ffecc7dc000-7ffecc7de000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

通过cat /proc/1/smaps可以看到更为详细每个映射区域的信息,我们拿一个area简单分析一下:

...
7f482e039000-7f482ed30000 r-xp 00000000 103:04 524415                    /usr/lib/jvm/java-1.8.0-openjdk-1.8.0.242.b08-0.50.amzn1.x86_64/jre/lib/amd64/server/libjvm.so
Size:              13276 kB // 映射区域内存大小
KernelPageSize:        4 kB // 页大小
MMUPageSize:           4 kB
Rss:               10780 kB // 实际加载到内存的大小
Pss:                 609 kB // 该有名映射与其他进程共享,这是除以共享进程后的值
Shared_Clean:      10780 kB // 全部都是共享的,且没有被修改过
Shared_Dirty:          0 kB // 
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:        10780 kB // 用于swap标记,具体可以见页置换
Anonymous:             0 kB // 匿名映射大小
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB // 锁住时,不能被置换
ProtectionKey:         0
VmFlags: rd ex mr mw me 
...

我们到一台Linux机器上敲出上面命令就可以看到任意一个进程的内存映射信息,最后一列有名字的是有名映射,无名字的则是匿名映射,那么什么是有名映射和匿名映射是什么关系呢?
有名映射就是指该块内存会关联到一定的外部持久化存储,在内存回收时需判断内存是否是脏的,也就是判断是否需要写回,有名映射主要操作的是mmap区域的内存,也就是我们常说的mmap,打开读写文件时会用,其中最常见的就是动态链接库,可执行文件的加载以及读写文件;而匿名映射在内存被回收时可以直接回收,无需额外的操作,匿名映射主要操作堆区的内存,也就是我们最常见的内存分配(申请数组,字符串等等)。

页表TLB 与 缺页中断

page mapping

page table

如上图所示,页表中存储了虚拟地址到物理地址的映射,而其实现方式就是一个多级索引,记录着虚拟地址到物理地址的映射关系,而管理页表的设备叫做MMU,所以每次访问内存时都需要先访问MMU来获取到物理地址,然后才能真正的访问到物理设备,只不过后面这个操作是内核实现的,用户感受不到这个过程。具体访问过程如下:(由于内存访问是高频操作,所以其性能很关键,所以为了减少页表的访问频率,内核会将页表缓存到CPU cache中,由于计算机访问局部性原理,cache机制可以大大提高查询效率)
MMU

当用户访问某个虚拟内存地址时,CPU会先访问MMU获取物理地址然后再访问到真实的物理地址。这里面你是否有一个疑问:既然每次访问内存都要经过MMU这样一个硬件设备,而之前上面又提到过所有访问硬件设备的操作都是危险的需要进入核心态保证安全性,那么每次访问内存都要陷入内核态?这样性能不得爆炸了?那么这里就需要说明一下:CPU这个硬件是一个特殊情况,由于其不存储状态,且CPU指令集是固定并遵循标准协议的,内核协议CPU的执行的所有指令都是健壮的,而MMU是CPU的组成部分,且不被用户感知,所以操作MMU就不需要陷入内核态,如果对应的指令需要访问额外的设备才会陷入内核态。
这里需要说明的一点是:当用户申请一块空间时,只是分配了对应的虚拟地址空间,但是也表里面并没有建立映射,而真正建立页表映射的是在访问对应的虚拟地址空间的时候进行的。当用户空间访问某个虚拟地址,而恰巧这个地址在页表中不存在时,就会触发缺页中断异常,之后就会调用缺页中断处理逻辑。异常处理逻辑其实很简单就是通过伙伴系统申请一个物理页然后写入页表。

物理内存管理

物理内存的管理比较偏底层,可能你并不关心,可以跳过这部分。
物理内存的管理主要需要考虑这么几个问题:

  1. 性能,计算机硬件发展迅猛,内存容量从最开始的几M到现在的几百G,CPU从单核到现在的几百核,如何能够在如此庞大的内存下依旧保证内存访问的高效性。
  2. 分配的通用性,能够应对不同类型的内存需求,比如IO缓存,进程内存等等。
  3. 内存管理的高效性,既能够高效的分配内存,又不至于过分复杂。
  4. 灵活性,能够分配几Byte到几GB不同大小的内存区域。

内存组织

总所周知,CPU是计算的核心,从而也是内存访问的主要入口,现代服务器及计算机内存块和CPU核数都能达到百级别,这样就需要一个不错的机制来做好CPU与内存之间的总线映射管理,以此来保证内存访问的高效性。

NUMA vs UMA
UMA

在旧版的计算机中,CPU核数和内存块都很少,所以会将内存串联成一个整体,CPU通过单一的总线来访问内存,显而易见cpu访问内存时单一总线成为了内存访问的瓶颈。

NUMA

随着计算机的发展这种单一总线模型已经不能满足大量内存块及CPU核数的需求,所以就提出了NUMA总线模型来更好的保证内存访问的并行性,大大提高了多内存多核情况下的内存访问性能。

内存域

为了更好的更好的应对不同的内容访问要求及特殊的性能优化,将内存划分为几个内存域:
ZONE_DMA标记适合DMA的内存域,在IA-32计算机上,一般的现在是16MB,该区域供I/O设备直接访问,不需要通过MMU管理,连续分配,具有更高的性能。
· ZONE_DMA32,标记了使用32位地址可寻址、适合DMA的内存域,显然只有64位系统上,才会有该内存域。
· ZONE_NORMAL,可以直接映射到内核段的普通内存域,这是所有体系机构上保证都会存在的唯一内存域,在IA-32系统上,该域可访问的最大内存不超过896MiB,超过该值的内存只能能通过高端内存寻址访问ZONE_HIGHMEM中的内存。
· ZONE_HIGHMEM,超出了内核段的物理内存。只有在可用物理内存多余可映射的内核内存时,才会访问该域,显然一般只有32位系统上才会有可能有该区域。通过kmap及kunmap将该域内存映射到内核虚拟地址空间。
· ZONE_MOVEABLE,这个区域主要是给用户空间分配使用。
前三个zone主要为内核所用到,最后一个主要被用户空间用到,内核空间内存域具体划分见下图:


image.png
伙伴系统

前面说到通过内存映射建立了虚拟地址空间到物理地址空间的一一对应关系,那么当用户或者内核申请一个16M的内存的话,物理内存管理应该如何划分并维护着12M内存的分配呢?总所周知常用计算机的分配最小单位页帧的大小为4KB,那么一个12M的内存分配需要41024=4096个页帧,如果一个页一个页的映射那么,性能也太低了。那么有什么好的方式能快速的分配一整块3072页的内存呢?所以内核就设计了伙伴系统*这么一个管理简单高效的分配策略。

伙伴系统承担内核初始完后的物理内存管理工作,负责管理各个内存域zone中的物理内存分配,释放。其基本工作原理如下:

  1. 把内存按照页划分成很多阶,最大阶为MAX_ORDER,一般设置为11,每个阶内存区的内存块数为2^n,我们称之为内存区。
  2. 当进程申请一段内存时,总是从适合大小的阶中分配指定内存区,比如当分配7k(4k * 2^1,7k离8k最近)内存的时候,会从第1阶分配对应的内存区。
  3. 当第k个阶的内存区全部被分完,没有可分配的k阶内存区时,会从第K+1阶划分出来两个新的内存区,供第K阶使用。比如第4阶的内存内存都分配完了,从第5阶分裂出来两个四阶的内存区。
  4. 当从高阶内存区划分出来的两个区都被释放时,该两个区的两个内存区会重新合并回高阶内存区。
    image.png

    当当前内存域没有可供分配的一整块连续内存时,就会向下一级内存域申请分配,如果下一级也没有就会到下一个备用节点(NUMA系统中的离当前CPU较远内存节点)分配
slab 缓存

对于malloc这个函数,大家来说应该都不陌生,这是系统库给我们提供了申请指定大小内存的函数,之前介绍的伙伴系统,只能以页的方式申请内存,对于小块(小于一页)内存的申请我们就得通过自定义的库函数来实现相关需求,所以在用户空间层面诞生了诸如ptmalloc(glibc),tcmalloc(google),jemalloc(facebook)等优秀的内存分配库。但是这些库内核没法使用,且内核也有大量申请小块内存的需求,诸如管理dentry,inode,fs_struct,page,task_struct等等一系列内核对象。所以内核提出了slab分配器,用来管理内核中小块内存分配,而cpu cache也是配合slab使用的,有时候也把slab称为缓存。

slab cache

slab缓存由两部分组成:保存管理性数据的缓存结构和保存被管理对象的各个slab结构,
一个slab就是一个页帧,而一个缓存下面维护着很多个页帧的slab,且一个slab中存储着很多个一样大小的slab对象。

其中缓存对象里面主要存储的信息包括:从内存加载至cpu_cache中的slab缓存对象数量限制;该缓存中slab缓存对象的大小(一个缓存中对象的slab对象大小都是一样大小的);该缓存结构占用的内存页数量;每个slab有多少个slab缓存对象;并维护着三个slab数组列表(全部空闲,全部满,部分空闲)。
当一个slab刚被申请时是放入全部空闲列表中的,等到开始分配时会分到部分空闲列表,最终没有可分配空间是被移到全部满列表。

slab struct

一个slab同样也需要一些元信息来描述这个页帧里面的slab对象,一个slab也是一个page,所以其管理数据结构其实就是存在page这个数据结构中,其中的元信息主要的是一个freelist用来记录当前页帧中还有哪些地址对应的slab对象尚未被使用。具体见下图:


slab data

缓存映射,在大学知识里面应该有提到,直接映射,组相连,全相连这样的概念,而用的比较多的是组相连,我们就以组相连为例简单介绍一下这个映射关系:其中一个缓存行可能存储多个slab对象,一个slab对象也可能跨越多个缓存行。


set associative mapping

比如一个地址0X1234,对应的页大小为4位,缓存为2位,虚拟地址是16位,物理地址是8位,cpu缓存为2路组相连。

  1. 那么前12位就是页表映射找到对应的页帧,也就是在页表中查找0X123对应的是哪个物理页。
  2. 通过页表找到物理页帧后,就会查找对应地址的slab对象是否在cpucache中。
  3. 这时候0B0100中的地三位就会定位到是组映射中的哪一组。
  4. 找到对应的cache组后,比对cache组中所有缓存行的tag是否==0x123,如果有相等则表示命中,否则不命中。

这里面有个slab cache coloring的性能优化技巧,什么是缓存着色呢?即在slab页帧的起始处加上一定的偏移这个偏移是缓存行的倍数。如果没有着色偏移的话,那么每次对应的缓存行一般都是从一个固定值开始的,假如我们忽略slab head的大小,每次slab对象都从页的起始位置开始分配,由于slab对象与cache line 一般来说是不等的,那么每次映射的缓存行一般是0 ~ 61或者0 ~ 62这种,所以62,63这种缓存行就会有较低访问概率,所以就需要这个着色偏移,让首尾缓存访问的概率趋于相同

文件缓存、有名映射

在前面的free 命令中可以看到内存会分为total,used,shared,buffers以及cached。
对于used和free这个比较熟悉就是真实进程自己使用的内存,那么shared,buffers,cached则是什么呢?
shared:是用于shared共享映射的内存,一般是通过shm调用共享内存或者mmap with MAP_SHARED。
buffers:是用于缓存磁盘块设备对象的内存,linux中磁盘块也是需要一些元数据进行管理的。
cached:则是缓存磁盘数据的内存。
那么什么是磁盘数据内存缓存呢?前面说到对于操作物理硬件设备的操作都是危险操作,都必须在内核态进行,所以磁盘设备的操作也需要在内核态执行,而Linux文件在操作系统中是全局共享的,所以对于磁盘文件可供所有程序共享使用,而且对于磁盘文件的读写都是以page页帧为单位的,因此每次open一个文件的时候,目标文件就会以cache的形式缓存在内核内存中,也就是free命令中的cached。
那么什么是有名映射?前面说到通过open打开的文件,其需要先缓存在内核态的内存中,所以用户态需要读数据时需要从内核内存拷贝到用户内存,这样多了一个拷贝操作,所以内核就开放了一个叫mmap的函数可以直接将文件映射到用户内存,这样操作用户内存最终会同步到磁盘文件,而且mmap支持shared及private两种模式,其中shared模式会写回磁盘文件,而private不会写回磁盘文件,所以c++动态链接文件一般是通过private mmap加载的。

用户内存管理(匿名映射)

其实对于大部分普通程序员来说,接触到最多的就是普通的内存申请,诸如new String, new Object,malloc之类的,这些对象在系统内存中是怎样被分配存储的呢?当调用delete操作释放内存时又是如何从系统中释放空间的?
也许很多c++程序员刚开始毕业找工作的时候,都会遇到一道面试题new和malloc的区别是啥?然后就会balabala的说一些什么new会调用构造函数之类的,new的底层其实也是由malloc实现的,那么malloc是怎么分配内存的呢?这里需要说明一点的是其实malloc也是一个抽象接口,其具体实现由好多种,诸如glibc的ptmalloc,Google的tcmalloc,Facebook的jemalloc;任何人都遵循malloc的标准自定义写一个malloc。这些malloc都有各自的特点,就不一一介绍了,有兴趣的同学可以自行百度。这里需要提到的一点就是,万变不离其宗,这些malloc最终都要涉及到怎么向操作系统申请和释放空间。那么操作系统暴露出来的两个个系统调用就是sys_brk以及sys_mmap,各个不同的malloc的区别就是调用这两个系统调用的时间及规则。但是这里需要重点说明一下的是:这里面的内存分配及回收都只涉及到虚拟地址空间,而物理地址空间都由内核单独管理。

内存申请及访问

前面介绍了用户空间的内存管理是通过内存映射来做的。memory mapping那张图上面当用户空间想要申请分配一块内存时,会先在已有内存映射中查找是否有指定大小的内存空间供可供使用,有则将对应的地址生成vm_area_struct对象并放入红黑树中;否则就会调用do_brk扩展对顶地址从而在堆内能够找到一块适合分配的虚拟地址空间。

内存释放

虚拟地址空间释放时调用do_unmap释放一个vm_area_struct对象,这样在堆内就会形成空洞,也就是我们所说的内存碎片,如果释放的vm_area_struct对象处于堆顶,则会将堆顶往下移。
见一个进程的内存使用

  #shell$ top -p 1
  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND                                                                                                
    1 root      20   0 19808 2756 2296 S  0.0  0.0   1:03.46 init 

其中VIRT是虚拟空间堆顶地址,RES是真正在页表中有记录的物理内存,SHR通过共享mmap以及shm分配的共享内存。

内存回收与交换

前面说到在调用malloc分配完内存时,其实只是申请了虚拟地址空间,而真正的内存分配是在缺页中断发生时。物理内存的管理完全在内核态,所以内核对物理内存又做了优化,这个优化就是内存回收与交换,以此来增大可用内存数量,也就是我们熟知的swap。
swap的策略就是通过将物理内存页划分队列active和inactive两个,当内存比较吃紧时就会将inactive中的内存回收或者放入交换区。那么active和inactive两个队列的中的内存移动我们叫做交换策略,具体如下图:


swap

swap的策略大家都应该很熟悉,就是我们面试经常会考的LRU最近最久未使用策略,但是这个策略性能不高,所以内核简化了其思想,通过维护页的一个引用标志位来实现,具体见上图,这里不详细介绍了。
需要强调一点:内存的换入换出是一个很耗时的操作,如果操作过于频繁会影响进程的性能和响应,而现代计算机基本内存充足,不希望由于置换带来的进程响应慢的后果,所以一般会关闭swap,但是一般的关闭swap操作只是关闭了必须写到swap分区的匿名映射,对于有名映射没法关闭swap,否则缓存cache将不可用。
具体可以看一下机器的内存详细信息

[root@host ~]# cat /proc/meminfo 
MemTotal:       32122940 kB // 机器内存总大小
MemFree:        12271688 kB // 空闲内存(完全为分配)
MemAvailable:   16046584 kB // 可用内存 free + cache + buffer
Buffers:          228000 kB
Cached:          3494896 kB
SwapCached:            0 kB
Active:         16426752 kB // 总的活跃内存
Inactive:        2593772 kB // 总的不活跃内存
Active(anon):   15297648 kB // 匿名映射中处于活跃的内存
Inactive(anon):       56 kB // 匿名映射中处于不活跃的内存
Active(file):    1129104 kB // 有名映射中处于活跃的内存(一般是mmap,加载的so)
Inactive(file):  2593716 kB // 有名映射中处于不活跃的内存(一般来说是文件缓存)
...
AnonPages:      15297620 kB
Mapped:           131552 kB
Shmem:                84 kB
Slab:             596432 kB // 内核使用
SReclaimable:     498372 kB  // 可被回收的,一般是一些缓存对象,诸如:dentry,inode
SUnreclaim:        98060 kB // 不可被回收的,一般是一些内核数据结构对象
KernelStack:       31216 kB //内核栈
PageTables:        64936 kB // 页表
NFS_Unstable:          0 kB
Bounce:                0 kB
WritebackTmp:          0 kB
CommitLimit:    16061468 kB
Committed_AS:   24740580 kB
VmallocTotal:   34359738367 kB // Vmalloc相关的
VmallocUsed:           0 kB
VmallocChunk:          0 kB
AnonHugePages:         0 kB
ShmemHugePages:        0 kB
ShmemPmdMapped:        0 kB
HugePages_Total:       0
HugePages_Free:        0
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB
DirectMap4k:       81892 kB // DMA内存大小
DirectMap2M:     3280896 kB
DirectMap1G:    30408704 kB

总结

Linux 对于内存管理主要包括两部分:虚拟地址空间管理及物理内存管理,虚拟地址空间主要包括内核空间及用户空间。内核空间主要存储内核对象、文件缓存,其中文件缓存包括磁盘文件,设备文件等的内容及其元信息等,内核对象的分配主要通过kmalloc分配,其底层实现是slab缓存。用户空间主要管理代码段,静态数据段,堆、栈、mmap及环境变量段。管理虚拟地址空间的方式是内存映射内存映射用来表示连续的整块虚拟内存分配,可以划分为有名映射和匿名映射,判断标准就是这块虚拟地址否关联到了一个文件。物理内存与虚拟内存的映射是通过通过页表映射来管理的,当用户空间访问某个虚拟地址时需要通过页表映射来找到对应的物理地址,这样就完成虚拟地址到物理地址的寻址。而内核虚拟地址与物理地址的寻址方式是直接映射,不需要依赖于页表映射。为了更好的管理和分配页,提出了伙伴系统这样一个思想来申请和分配一个或多个页帧,伙伴系统的思想非常简洁高效。内核为了保证更充分的内存利用,就提出了内存置换和回收机制,当内存不足时能够回收或者换出一些最近没怎么使用的内存,这样能够更好的提高内存使用率,这样对于文件缓存的管理也变得更加容易,不需要额外的工作,当内存不足时,文件缓存内存大概率是可以被回收和置换的。
具体相关关系见下图:

memory overview

扩展阅读

大家想必对于docker和lxc容器都有过了解,那么lxc容器是怎么管理内存的呢?众所周知,lxc是由namespace及cgroup组成的,而lxc相关的内存管理其也是借助于cgroup的来管理的,但是内存管理没有独立的namespace。

那么cgroup是怎么限制一个memory group的内存访问上线的呢?内核在2.6之后提出了mem group的概念,一个group可以指定一些限制,诸如最大内存使用上限等。每个页帧在分配时都会关联到所在的group,并进行相关统计,当group内的内存达到一定的内存水位时,就会触发内存回收和置换,保证group内内存处于水位线下,如果经过多次回收和置换都达不到水位线下时就会触发OOM killer。

你可能感兴趣的:(深入Linux内核架构--内存管理设计介绍)