开发内功修炼@张彦飞开发内功修炼@张彦飞

talk is cheap,
show me the code!

Linux 内核“偷吃”掉了我的内存

大家好,我是飞哥!

今天我来拋个问题出来,那就是 Linux 内核给我们使用的内存“并不足量”。拿我手头的一台虚拟机来举例(物理机原理一样),通过 dmidecode 命令查看到这台服务器是一条 16384 MB 的内存。

# dmidecode
Memory Device
    Total Width: Unknown
    Data Width: Unknown
    Size: 16384 MB
    Manufacturer: QEMU
......

但是使用 free 查看的时候,Linux 却告诉我们只有 15773 MB 可用。

# free -m
              total        used        free      shared  buff/cache   available
Mem:          15773         794       13708          54        1270       14688
Swap:             0           0           0

那问题来了,16384 和 15773 中间差的这 611 MB 跑哪儿去了? 其实答案也并不玄乎,就是内核自己用掉了。但要想理解内核是如何使用掉这些内存的需要对内核的物理内存管理机制有足够深入的了解才行。

本节就让我们带着这个问题来学习下内核对物理内存的管理方式。

一、Linux 初期的 memblock 内存分配器

提到物理内存的管理,很多同学想到的都是伙伴系统。但其实内核中采用了两种内存管理机制来管理物理内存。

第一种是初期内存分配器。在内核刚启动的时候,采用的是较为简单的内存分配器。这种内存分配器仅仅只为了满足系统启动时间内对内存页的简单管理。第二种就是我们熟知的伙伴系统,这是在 Linux 系统正常运行时期采用的主要分配器。

在 Linux 的早期版本中,初期分配器采用的是 bootmem。但在 2010 年之后,就慢慢替换成了 memblock 内存分配器。可以参见这个历史 commit https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/mm/memblock.c?id=95f72d1ed41a66f1c1c29c24d479de81a0bea36f

1.1 创建 memblock 内存分配器

我们来看下 memblock 内存分配器是如何实现的。在 《Linux 内核是如何检测可用物理内存地址范围的?》 一文中我们看到了 Linux 内核检测物理内存的工作原理。在检测完成后调用 e820__memory_setup 来把检测结果保存到了 e820_table 全局数据结构中。

//file:arch/x86/kernel/setup.c
void __init setup_arch(char **cmdline_p)
{
    ...
    // 保存物理内存检测结果
    e820__memory_setup();
    ...

    // membloc内存分配器初始化
    e820__memblock_setup();
}

在保存完内存检测结果后,Linux 内核就会调用 e820__memblock_setup 来创建 memblock 内存分配器。在看创建 memblock 之前我们先来看看这种内存分配器是长什么样子的。memblock 的实现非常简单,就是按照检测到的内存地址范围是 usable 还是 reserved 分成两个对象,然后分别用两个数组给存起来。

图1.png

它的相关代码如下。

//file:mm/memblock.c
struct memblock memblock __initdata_memblock = {
    .memory.regions        = memblock_memory_init_regions,
    .memory.cnt        = 1,    /* empty dummy entry */
    .memory.max        = INIT_MEMBLOCK_MEMORY_REGIONS,
    .memory.name        = "memory",

    .reserved.regions    = memblock_reserved_init_regions,
    .reserved.cnt        = 1,    /* empty dummy entry */
    .reserved.max        = INIT_MEMBLOCK_RESERVED_REGIONS,
    .reserved.name        = "reserved",

    .bottom_up        = false,
    .current_limit        = MEMBLOCK_ALLOC_ANYWHERE,
}

#define INIT_MEMBLOCK_REGIONS            128
#define INIT_MEMBLOCK_RESERVED_REGIONS        INIT_MEMBLOCK_REGIONS
#define INIT_MEMBLOCK_MEMORY_REGIONS        INIT_MEMBLOCK_REGIONS

static struct memblock_region memblock_memory_init_regions[INIT_MEMBLOCK_MEMORY_REGIONS] __initdata_memblock;
static struct memblock_region memblock_reserved_init_regions[INIT_MEMBLOCK_RESERVED_REGIONS] __initdata_memblock;

说完了 memblock 内存分配器器的实现,我们再来看它的构造过程。在探测到内存布局后,调用 e820__memblock_setup 来对 memblock 内存分配器进行创建。

//file:arch/x86/kernel/e820.c
void __init e820__memblock_setup(void)
{
    ...
    for (i = 0; i < e820_table->nr_entries; i++) {
        struct e820_entry *entry = &e820_table->entries[i];
        ...
        if (entry->type == E820_TYPE_SOFT_RESERVED)
            memblock_reserve(entry->addr, entry->size);

        memblock_add(entry->addr, entry->size);
    }

    // 打印 memblock 创建结果
    memblock_dump_all();
}

memblock 内存分配器的创建过程是遍历 e820 table 中的每一段内存区域。

判断如果是预留内存就调用 memblock_reserve 添加到 reserved 成员中,也就是预留内存列表。添加过程是会修改 reserved 中的区域数量 cnt,然后在设置 regions 中的一个元素。如果是可用内存就调用 memblock_add 添加到 memory 成员中,也就是可用内存列表。添加过程同上。

在 memblock 创建完成后,紧接着还调用 memblock_dump_all() 进行了一次打印输出。关于这个输出,我们在下面的小节来查看。

1.2 查看 memblock 的日志信息

Linux 内核在启动的时候可以记录 memblock 创建日志。但需要在 Linux 启动参数中添加 memblock=debug 参数并重启机器才可以。

我在我的机器上是修改了 /boot/grub/grub.cfg 文件,在启动参数中添加了 memblock=debug。

linux   /boot/vmlinuz-5.4.143.bsk.8-amd64 root=UUID=b4116c79-9698-42a6-8379-47befe6f963a ...... memblock=debug

然后重启后,通过查看 /proc/cmdline 确认开启生效。

# cat /proc/cmdline
BOOT_IMAGE=/boot/vmlinuz-5.4.143.bsk.8-amd64 ...... memblock=debug

然后就可以通过 dmseg 可以看到 memblock 内存分配器创建输出的各种日志信息了。

......
[    0.010238] MEMBLOCK configuration:
[    0.010239]  memory size = 0x00000003fff78c00 reserved size = 0x0000000003c6d144
[    0.010240]  memory.cnt  = 0x3
[    0.010241]  memory[0x0]    [0x0000000000001000-0x000000000009efff], 0x000000000009e000 bytes flags: 0x0
[    0.010243]  memory[0x1]    [0x0000000000100000-0x00000000bffd9fff], 0x00000000bfeda000 bytes flags: 0x0
[    0.010244]  memory[0x2]    [0x0000000100000000-0x000000043fffffff], 0x0000000340000000 bytes flags: 0x0
[    0.010245]  reserved.cnt  = 0x4
[    0.010246]  reserved[0x0]    [0x0000000000000000-0x0000000000000fff], 0x0000000000001000 bytes flags: 0x0
[    0.010247]  reserved[0x1]    [0x00000000000f5a40-0x00000000000f5b83], 0x0000000000000144 bytes flags: 0x0
[    0.010248]  reserved[0x2]    [0x0000000001000000-0x000000000340cfff], 0x000000000240d000 bytes flags: 0x0
[    0.010249]  reserved[0x3]    [0x0000000034f31000-0x000000003678ffff], 0x000000000185f000 bytes flags: 0x0
......

在上面的输出示例中,可以看到总物理内存是 0x3fff78c00 (约 1.596 TB),保留内存:0x3c6d144 (约 61 MB)。包含 3 个内存块,每个内存块的地址范围等等信息。

二、向 memblock 内存分配器申请内存

内核在启动的过程中,伙伴系统没有创建。这时所有的内存都是通过 memblock 内存分配器来分配的。比较重要的两个使用的场景是 crash kernel 和 页管理初始化。

2.1 crash kernel

一般我们认为在服务器上是有一套内核。但实际上,服务器是启动了两个内核,第一个是正常使用的内核,第二个是崩溃发生时kdump机制使用的应急内核。

内核为了在崩溃时能记录崩溃的现场,方便以后排查分析,设计实现了一套 kdump 机制。

有了 kdump 机制,发生系统崩溃的时候 kdump 使用 kexec 启动到第二个内核中运行。这样第一个内核中的内存就得以保留下来了。然后可以把崩溃时的所有运行状态都收集到 dump core 中。

这里我们不对 kdump 机制过多展开,我们想重点说的是这套机制是需要额外的内存才能工作的。通过 reserve_crashkernel_low 和 reserve_crashkernel 两个函数向 memblock 内存分配器申请内存。

//file:arch/x86/kernel/setup.c
static int __init reserve_crashkernel_low(void)
{
    ...
    // 申请内存
    low_base = memblock_phys_alloc_range(low_size, CRASH_ALIGN, 0, CRASH_ADDR_LOW_MAX);
    pr_info("Reserving %ldMB of low memory at %ldMB for crashkernel (low RAM limit: %ldMB)\n",
        (unsigned long)(low_size >> 20),
        (unsigned long)(low_base >> 20),
        (unsigned long)(low_mem_limit >> 20));
    ...
}

static void __init reserve_crashkernel(void)
{
    ...
    // 申请内存
    low_base = memblock_phys_alloc_range(low_size, CRASH_ALIGN, 0, CRASH_ADDR_LOW_MAX);
    pr_info("Reserving %ldMB of memory at %ldMB for crashkernel (System RAM: %ldMB)\n",
        (unsigned long)(crash_size >> 20),
        (unsigned long)(crash_base >> 20),
        (unsigned long)(total_mem >> 20));
}

这两个内存都在申请完内存后把信息通过日志的方式打印出来了,在 dmesg 的输出中可以看到。

......
[    0.010832] Reserving 128MB of low memory at 2928MB for crashkernel (System low RAM: 3071MB)
[    0.010835] Reserving 128MB of memory at 17264MB for crashkernel (System RAM: 16383MB)

在我的这台虚拟机中,总共为 crash kernel 预留了两个 128 MB,总共 512 MB 的内存。这些内存会一直被占用,我们自己的用户程序无法使用。

2.2 页管理初始化

我们都知道,Linux 是通过页的方式来管理所有的物理内存的。页的大小是 4KB。

然而每一个页都需要使用一个 struct page 对象来表示。这个对象也是需要消耗内存的。在不同的版本中,struct page 的大小不一样,一般比较常见的大小是 64 字节。

//file:include/linux/mm_types.h
struct page {
    unsigned long flags;
    ...
}

页管理机制的初始化具体函数是 paging_init,具体的执行路径是在 start_kernel -> setup_arch -> x86_init.paging.pagetable_init -> paging_init。

start_kernel
-> setup_arch
---> e820__memory_setup   // 内核把物理内存检测保存从boot_params.e820_table保存到e820_table中,并打印出来
---> e820__memblock_setup // 根据e820信息构建memblock内存分配器,开启调试能打印
---> x86_init.paging.pagetable_init(native_pagetable_init)
-----> paging_init        // 页管理机制的初始化
->mm_init
--->mem_init
-----> memblock_free_all  // 向伙伴系统移交控制权

在 paging_init 这个函数中为所有的页面都申请一个 struct page 对象。将来通过这个对象来对页面进行管理。

内存页管理模型也经过了几代的变化,在最早的时候,采用的是 FLAT 模型、中间还经历了 DISCONTIG 模型,现在都默认采用了 SPARSEMEM 模型。 SPARSEMEM 模型在内存中就是一个二维数组。

//file:mm/sparse.c
#ifdef CONFIG_SPARSEMEM_EXTREME
struct mem_section **mem_section;
#else
struct mem_section mem_section[NR_SECTION_ROOTS][SECTIONS_PER_ROOT]
    ____cacheline_internodealigned_in_smp;
#endif
EXPORT_SYMBOL(mem_section);

在这个二维数组中,通过层层包装,最后包含的最小单元就是表示内存的 struct page 对象。

图2.png

假设 struct page 结构体大小是 64 字节。那么平均每 4 KB 就额外需要消耗 64 字节内存用来存储这个对象。 64/4096 约等于 1.56%,那么管理 16 GB 的内存大约需要 (16*1024 MB) * 1.56%,约 256 MB 的内存。

三、向伙伴系统释放可用内存

在经过内存检测、memblock 内存分配器构建、页管理机制初始化等步骤后,伙伴管理系统就要登场了。在 mm_init -> mem_init -> memblock_free_all 中, memblock 开启了它给伙伴系统交接内存的交接仪式。

start_kernel
-> setup_arch
---> e820__memory_setup   // 内核把物理内存检测保存从boot_params.e820_table保存到e820_table中,并打印出来
---> e820__memblock_setup // 根据e820信息构建memblock内存分配器,开启调试能打印
---> x86_init.paging.pagetable_init(native_pagetable_init)
-----> paging_init        // 页管理机制的初始化
->mm_init
--->mem_init
-----> memblock_free_all  // 向伙伴系统移交控制权

我们来看下内存的交接过程。

//file:mm/memblock.c
void __init memblock_free_all(void)
{
    unsigned long pages;
    ......
    pages = free_low_memory_core_early();
    totalram_pages_add(pages);
}

具体的释放是在 free_low_memory_core_early 中进行的。值得注意的是,memblock 是把 reserved 和可用内存是分开来交接的。这样保证 reserved 内存即使交接给了伙伴系统,伙伴系统也不会把它分配出去给用户程序使用。

// mm/memblock.c
static unsigned long __init free_low_memory_core_early(void)
{
    // reserve 内存交接
    memmap_init_reserved_pages();

    // 可用内存交接
    for_each_free_mem_range(i, NUMA_NO_NODE, MEMBLOCK_NONE, &start, &end,
                NULL)
        count += __free_memory_core(start, end);

    return count;
}

在交接完毕后,返回交接的内存页数。这个页数通过 totalram_pages_add 函数添加到一个名为 \_totalram_pages 的全局变量中了。

//file:mm/page_alloc.c
atomic_long_t _totalram_pages __read_mostly;
EXPORT_SYMBOL(_totalram_pages);

四、总结

Linux 并不会把全部的物理内存都提供给我们使用。所以如果你通过 free -m 查看到可用内存比实际的物理内存小也丝毫不用感到奇怪。

Linux 为了维护自身的运行,会需要消耗一些内存。在本文中我们介绍了 kdump 机制对内存的消耗,也介绍了内存的页管理机制对内存的占用。但实际上还以一些其他的消耗,例如 NUMA 机制中的 node 、zone 的管理等等也都需要内存。

另外在本文中,我们也给大家介绍了 memblock 内存分配器。内核在启动检测到内存的地址布局后,会用这个布局来初始化 memblock 内存分配器。后面内核的 kdump 机制、页管理机制、NUMA 初始化等再需要使用内存的时候,都是向 memblock 分配器来申请内存的。其中页管理机制中 struct page 的开销大约就需要 1.56%。

当内核启动工作执行的差不多的时候,memblock 会将内存管理交接给伙伴系统。memblock 是把 reserved 和可用内存是分开来交接的。这样保证 reserved 内存即使交接给了伙伴系统,伙伴系统也不会把它分配出去给用户程序使用。

以上就是内核在启动时对物理内存的管理和使用原理。

最后,除了内核之外,其实应用层面在内存的使用上也会有一定程度的管理开销。拿 golang 内存分配器 TCMalloc 来举例,其核心数据结构 mspan 定义如下。

// src/runtime/mheap.go
type mspan struct {
    next *mspan     // 下一个span节点
    prev *mspan     // 上一个span节点 

    allocBits  *gcBits   // 标记内存的占用
    gcmarkBits *gcBits   // 标记内存的GC回收情况
    ...
}

在 mspan 中会有一些必须的元数组,例如组成 span 链表的指针,以及标记 span 中对象分配的位图、标记内存 GC 扫描时的位图等等。这些都是无法被应用程序使用的。


我打造了一套更为完整的视频课程。总共包括了硬件原理、内存管理、进程管理、网络管理、文件系统、容器原理、性能观测、性能优化、调用原理、等几个大部分的内容。

其中有 5 个大部分已经更新完毕,下图中彩色部分为已更新内容,总时长 1900 多分钟。

剩下各个大部分还在持续更新中。

除了视频内容,星球里我也给大家开放各种电子 PDF、PPT 的下载。

目前我给大家仍然还开放 200 元的大额优惠券。加入后一年是 299 元。后期再续费目前是 6 折,也是 299。直接使用下方优惠券加入即可看上面的这些视频内容。

本原创文章未经允许不得转载 | 当前页面:开发内功修炼@张彦飞 » Linux 内核“偷吃”掉了我的内存