【译】Linux——内存管理是如何工作的?转载
与 CPU 管理一样,内存管理也是操作系统的核心功能之一。内存主要用于存储系统和应用程序指令、数据、缓存等。
内存映射
我们通常所说的内存容量,其实是指物理内存。物理内存也称为主内存,大多数计算机使用的主内存是动态随机存取存储器(DRAM)。只有内核可以直接访问物理内存。那么,进程想要访问内存时应该怎么做呢?
Linux内核为每个进程提供了一个独立的虚拟地址空间,这个地址空间是连续的。这样,进程就可以方便地访问内存,更准确地说是虚拟内存。
虚拟地址空间内部分为内核空间和用户空间两部分。具有不同字长(单个 CPU 指令可以处理的最大数据长度)的处理器具有不同的地址空间范围。例如,对于 32 位和 64 位系统,下图显示了它们的虚拟内存空间:
从上图可以看出,32位系统的内核空间为1G,位于最高点,剩下的3G为用户空间。64位系统的内核空间和用户空间都是128T,中间的其余部分未定义。
还记得进程的用户模式和内核模式吗?当一个进程处于用户态时,它只能访问用户空间内存;只有进入内核模式后才能访问内核空间内存。虽然每个进程的地址空间都包含内核空间,但这些内核空间实际上是与同一个物理内存相关联的。这样,进程切换到内核模式后,就可以轻松访问内核空间内存。
由于每个进程都有这么大的地址空间,所有进程加起来的虚拟内存自然要比实际的物理内存大很多。因此,并不是所有的虚拟内存都会分配物理内存,只有实际使用的虚拟内存才会分配物理内存,分配的物理内存是通过内存映射来管理的。
内存映射实际上是虚拟内存地址到物理内存地址的映射。为了完成内存映射,内核为每个进程维护一张页表,记录虚拟内存地址和物理地址的映射关系:
页表实际上存储在 CPU 的 MMU(内存管理单元)中。当在页表中找不到进程访问的虚拟地址时,系统会产生“缺页异常”,进入内核空间定位物理内存,然后更新进程页表,最后返回用户空间恢复进程。
TLB(Translation Lookaside Butter)是 MMU 中的页表缓存。由于进程的虚拟地址空间是独立的,而且TLB的访问速度比MMU快很多,通过减少进程的上下文切换和减少TLB的刷新次数,可以提高TLB缓存的利用率进行改进,从而提高 CPU 性能。
不过需要注意的是,MMU(一页)的最小单位通常是4KB。这样,每个内存映射需要关联一个4KB的内存空间。4KB 大小可能会导致另一个问题,即整个页表可能非常大。例如,仅 32 位系统就需要超过 1M 的页表条目 (4GB/4KB)。为了解决页表项过多的问题,Linux提供了两种机制:多级页表和大页。
多级页表 vs 大页
多级页表就是把内存分成块来管理,改变原来的映射关系到块索引和块内的偏移量。由于通常只使用了一小部分虚拟内存空间,因此多级页表只保存了这些正在使用的块,可以大大减少页表项的数量。
Linux 使用四级页表来管理内存页。如下图所示,虚拟地址分为5部分,前4个条目用于选择页面,最后一个索引代表页面内的偏移量。
看大页,顾名思义,就是比普通页更大的内存块,常见的大小有2MB和1GB。大页通常用在使用大量内存的进程中,例如 Oracle、DPDK 等。
虚拟内存空间分布
首先,我们需要了解更多关于虚拟内存空间的分布情况。以 32 位系统为例,关系如下:
- 只读段:包括代码和常量等
- 数据段:包括全局变量等
- 堆:包括动态分配的内存,从低地址开始向上增长
映射区域:包括动态库、共享库等。从高地址开始向下增长。 - 堆栈:包括局部变量和函数调用的上下文等。堆栈大小固定为8MB。
在五个内存段中,堆和映射区域段的内存是动态分配的。例如,使用 C 标准库 malloc() 或 mmap(),可以分别在堆和文件映射段中动态分配内存。
内存分配和回收
malloc()是C标准库提供的内存分配函数,对应系统调用,有两种实现,分别是brk()和mmap()。
- brk():小块内存(≤128K),移动堆顶。brk()分配的内存不返回给系统,提高了内存访问效率。
- mmap():在映射区域分配内存。分配的内存在释放时直接返回给系统。
要记住的一件事是,当调用这两个函数时,实际上并没有分配内存。内存只在第一次访问时分配,即进程通过缺页异常进入内核,然后内核分配内存。
当发现内存不足时,系统会通过一系列机制回收内存,例如以下三种方式:
- 回收缓存,比如使用LRU(Least Recent Used)算法回收最近最少使用的内存页;
- 回收不经常访问的内存,将不经常使用的内存通过交换分区直接写入磁盘;
- 杀死进程。当内存紧张时,系统会通过OOM(Out of Memory)直接杀死占用大量内存的进程。
OOM 用于oom_score对每个进程的内存使用情况进行评分:
- 进程消耗的内存越大,oom_score
- 进程运行所需的 CPU 越多,进程越小oom_score
当然,针对实际工作需要,管理员可以 oom_adj通过/proc文件系统手动设置进程的进程,从而调整oom_score进程的进程。
范围oom_adj是[-17, 15],值越大越容易被OOM杀死;该值越小,进程被OOM杀死的可能性越小,其中-17表示禁止OOM。我们来看看sshd过程:
$ ps -ef | grep sshd
root 3218 1 0 2021?00:02:25 /usr/**in/sshd -D
root 31328 3218 0 20:12?00:00:00 sshd:ubuntu [priv]
ubuntu 31461 31328 0 20:12?00:00:00 sshd:ubuntu@pts/0
ubuntu 31497 31462 0 20:13 pts/0 00:00:00 grep --color=auto sshd
$ cat /proc/3218/oom_adj
-17
如你所见,进程的oom_adj设置为sshd-17。
如何检查内存使用情况
你可以使用free,top或pscommand 来检查系统内存使用情况。free显示整个系统的内存使用情况。如果要检查进程的内存使用情况,可以使用top或ps。
free
$ free -h
total used free shared buff/cache available
Mem: 1.9G 1.0G 81M 2.6M 796M 715M
Swap: 0B 0B 0B
- 第一列,total是总内存大小
- 第二列,used 是已用内存的大小,包括共享内存
- 第三列,free是未使用内存的大小
- 第四列,shared是共享内存的大小
- 第五列,buff/cache是cache和buffer的大小
- 最后一列可用,是新进程可用的内存量
top
# 按 M 按内存排序
$ top
...
KiB Mem : 总共 8169348, 6871440 免费, 267096 已使用, 1030812 buff/cache
KiB Swap: 总共 0, 0 免费, 0已使用。7607492 利用 Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
430 root 19 -1 122360 35588 23748 S 0.0 0.4 0:32.17 systemd-journal
1075 root 20 0 771860 22744 11368 S 0.0 010948 0:38.80
snapd 1 17292 9488 S 0.0 0.2 0:00.24 networkd-dispat
1 root 20 0 78020 9156 6644 S 0.0 0.1 0:22.92 systemd
12376 azure 20 0 76632 7456 6420 S 0.0 0.1 0:00.01 systemd
12374 root 20 0 107984 7312 6304 S 0.0 0.1 0 :00.00 sshd
...
顶部的输出界面还显示了系统的整体内存使用情况。这些数据与free类似,不再赘述。我们来看下面的内容,几列与内存相关的数据,如VIRT、RES、SHR、%MEM。
- VIRT是进程的虚拟内存大小。只要是进程申请的内存,即使物理内存没有实际分配,也会被计算在内。
- RES是常驻内存的大小,即进程实际使用的物理内存的大小,不包括Swap和共享内存。
- SHR是共享内存的大小,比如其他进程使用的共享内存、加载的动态链接库、程序代码段等。
- %MEM是进程使用的物理内存占系统总内存的百分比。
结论
对于普通进程,我们能看到的是内核提供的虚拟内存。这些虚拟内存也需要系统通过页表映射到物理内存。
当一个进程通过malloc()申请内存时,内存不是立即分配的,而是在第一次访问时通过缺页异常在内核中分配内存。
由于进程的虚拟地址空间远大于物理内存,Linux还提供了一系列机制来处理内存不足,如缓存回收、交换分区、OOM等。