内存领域知识

来自:

OOMKiller

Linux 的 overcommit 机制

  • 如果关闭 overcommit 机制,那么大部分的 OOM 现象将会消失,因为系统分配内存会更加保守
  • 如果开启 overcommit 机制,会根据物理内存、swap 和 overcommit_ratio 判断是否能够分配出内存,依然可能会分配失败
    1
    CommitLimit = [swap size] + [RAM size] * vm.overcommit_ratio / 100

mmap

mmap

几个 flag:

  • MAP_SHARED
  • MAP_SHARED_VALIDATE
  • MAP_PRIVATE
    创建一个 private 的 COW mapping。对这个 mapping 的修改不会对其他进程可见,也不会写到下面的文件里面。
    It is unspecified whether changes made to the file after the mmap() call are visible in the mapped region.

msync

表示从内存刷回到底层文件中。除此之外,只有 munmap 能确保进行这样的刷盘。

  • MS_ASYNC
  • MS_SYNC
  • MS_INVALIDATE
    Asks to invalidate other mappings of the same file (so that they can be updated with the fresh values just written)

madvise

  • MADV_NORMAL
    默认行为,内核根据普通访问模式处理页面。

  • MADV_RANDOM
    随机访问,可能减少 prefetch 操作。

  • MADV_SEQUENTIAL
    顺序访问,可以增加 prefetch。

  • MADV_WILLNEED
    表示该内存区域将在未来使用,内核可以提前加载到内存。

  • MADV_DONTNEED / MADV_FREE / munmap
    三个都是释放内存。后面详细描述。

  • MADV_REMOVE
    从文件映射中移除内存区域,释放物理内存,同时在映射的文件中移除相应内容(需要文件映射)。

  • MADV_DONTFORK
    子进程不会继承该内存区域。

  • MADV_DOFORK

  • MADV_MERGEABLE
    启用内存合并(KSM,Kernel Samepage Merging),允许内核将具有相同内容的内存页面合并以节省内存。

  • MADV_UNMERGEABLE

  • MADV_HUGEPAGE (since Linux 2.6.38)
    表示要去分配一个透明大页,即 Transparent Huge Pages (THP)。

    • 内核会不停扫描,将一段内存变为透明大页
    • 如果分配的地址是对齐的,那么内核可能直接分配为透明大页

    大部分通用的内核都会选择默认开启 THP,而那些嵌入式的系统则可能选择不默认开启。

  • MADV_COLLAPSE
    尽力将普通页面转换为透明大页。

  • MADV_NOHUGEPAGE

  • MADV_SOFT_OFFLINE
    将指定区域中的坏内存页标记为不可用,但不杀死当前进程。

  • MADV_HWPOISON
    强制将页面标记为硬件错误(仅管理员权限可用)。

三个释放内存:

  • munmap
    即时释放物理内存(RSS)以及虚拟地址。
  • MADV_DONTNEED
    即时释放物理内存(RSS)。保留虚拟地址,但可能会造成虚拟地址紧张。
  • MADV_FREE
    延迟释放。

进一步可以参考 https://zhuanlan.zhihu.com/p/570033679。我理解从 munmap 到 MADV_FREE 代价越来越小。

关于透明大页

使用透明大页的缺点:

  • 透明大页的后台进程 khugepaged 在扫描到目标页时,会短暂挂起进程,这会影响内存读写操作的延迟。
  • 在 CoW 时,会产生写放大。
  • 出现 NUMA 跨节点访问的时候,也会出现放大。

Linux 的物理内存管理

分页机制

原理

当 CR0 寄存器中的 PG 位被设置时标志着分页机制启动,分页机制负责将线性空间中的内存按照页映射到物理空间中的页框(page frame)中。以 386 为例,页面地址按照 4K 边界对齐,这样一个进程 4G 的地址空间就会被映射成为 1M 个页面。这些页的地址占共 4M 的内存。
等下,不是说按照 4K 边界对齐的么,这样 20 位就够了呀,为什么页面项还要占用 4 个字节共 32 位呢?

  • 这 20 位不整齐
  • 低12位会被用来放一些控制相关的信息。例如
    • 标记页的特权级
    • 页是否在内存中
    • PWT 指示是否写透,也就是既写 Cache 又写 RAM
    • PCD 表示是否使用 Cache
    • 扩展分页标志,可以让一个页面扩展为4MB大小

为了减少内存使用,使用两级页表机制,即页目录和(真正的)页表,各有 1K 个项目。页目录只有在需要时才会创建页表。当然,更新的系统中可能会使用更多级的页表,但总体原理是一样的。

因此现在一个 32 位的地址被分成了三段,高 10 位用来索引一个页目录表项(描述一个页表的基址),中 10 位用来索引一个页表项(描述一个页的基址),低 12 位用来确定在页面内的具体偏移。
当系统接受到一个线性地址时,它首先会使用高 10 位在页目录表中索引出对应的页目录表项,用中 10 位索引出对应的页表项,再加上最后 12 位的偏移。

不过页目录表的基址在哪里呢?处理器用 CR3 来放置这个值。

此外,CPU 中有了一个页面高速缓存(TLB),能够自动保存 32 项最近使用过的页面地址,也就是能覆盖 128K 的地址,所以在访存时可以先查 TLB 来获得直接的内存地址,找不到再查页表。

物理内存用途

Linux 把物理内存页按用途大致分为:

  • Anonymous pages
    进程堆、栈、匿名 mmap
  • Page cache
    文件内容缓存页,在文件 I/O 时产生。
  • Slab / Kernel pages
    内核数据结构
  • Free pages
    空闲页

在 Anonymous pages 和 Page cache 中的 page 可能是 inactive 的。对于前者,可以 swap,对于后者,可以写回或者丢弃。

PageCache 和缺页中断

伙伴系统和 slab

CSAPP Malloc Lab中介绍了 Linux 的伙伴系统,这个机制维护了大小以 2 位底数的空闲内存块。但在一些方面,空闲链表是有缺陷的,链表的特性导致我们没有一个较好的办法去知道全局的状态。
Slab 将不同对象划分为高速缓存组,以储存不同的对象。这是因为 Jeff Bonwick 认为 Linux 中的对象,例如互斥锁、task_struct、inode 这些会频繁地创建或者释放的结构,他们的初始化的代价是较高的,因此与其将这些对象释放会内存池,不如将其保留下来一遍下一次使用。因此 Slab 层将这些对象按类型放到不同的高速缓存 kmem_cache 中。每一个高速缓存中分了多个 slab,通常对应着物理内存上的若干页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 2.6.39.4
struct slab {
union {
struct {
struct list_head list;
unsigned long colouroff;
void *s_mem; /* including colour offset */
unsigned int inuse; /* num of objs active in slab */
kmem_bufctl_t free;
unsigned short nodeid;
};
struct slab_rcu __slab_cover_slab_rcu;
};
};
struct kmem_list3 {
struct list_head slabs_partial; /* partial list first, better asm code */
struct list_head slabs_full;
struct list_head slabs_free;
unsigned long free_objects;
unsigned int free_limit;
unsigned int colour_next; /* Per-node cache coloring */
spinlock_t list_lock;
struct array_cache *shared; /* shared per node */
struct array_cache **alien; /* on other nodes */
unsigned long next_reap; /* updated without locking */
int free_touched; /* updated without locking */
};

创建一个高速缓存的函数如下。其中:

  • name 是缓冲区的名字
  • size 是每个元素的大小
  • align 用来保证对齐,一般是 0 或者 L1_CACHE_BYTES ,也就是 L1 的大小
  • flags 常见的 flags 有:
    • SLAB_HWCACHE_ALIGN,用来强制 slab 内的所有对象按照缓存行对齐,这样可以防止伪共享。详见我的博文。但会造成一定的开销,选项 SLAB_PANIC 顾名思义,申请失败就 panic。
  • 第五个是缓冲区的构造函数,不过并没有被使用。
1
2
3
struct kmem_cache *
kmem_cache_create (const char *name, size_t size, size_t align,
unsigned long flags, void (*ctor)(void *))'

在已有高速缓存后可以通过下面的函数获取一个对象

1
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags)

NUMA 架构

UMA(Uniform Memory Access):

  • 所有 CPU 访问内存的延迟相同,常见于单处理器或 SMP
  • 这种情况下,CPU 通过 FSB 总线连接到北桥,然后北桥的内存控制器连接到内存。

NUMA(Non-Uniform Memory Access):

  • 原因:提高 CPU 频率 -> 增加 CPU 数量。越来越多的 CPU 对 FSB 总线形成争抢,因此引入了 NUMA。
  • 实现
    • 内存控制器集成到 CPU 内部,一般一个 CPU socket 会有一个独立的内存控制器。
    • 每个 CPU socket 独立连接到一部分内存,这部分 CPU 直连的内存称为本地内存。
    • CPU 之间通过 QPI(Quick Path Interconnect) 总线进行连接。通过该总线,要和可以访问不直连的远程内存。

numactl

一个进程在 numa 上运行,需要考虑:

  • 它的各个线程在哪个 cpu 上运行
    指定 –cpubind 表示在哪些节点上的 cpu 上运行。
    指定 –physcpubind 表示在哪些 cpu 核心上运行。
    可以通过 numactl –hardware 查看节点与 cpu 核心的映射:

    1
    2
    3
    available: 2 nodes (0-1)
    node 0 cpus: 0-7
    node 1 cpus: 8-15
  • 内存从哪个节点分配
    指定 –membind 则一定在指定的节点上分配,如果无法分配则失败。
    指定 –interleave 则表示在这些节点上按照 round robin 的方式分配。

numa 和 swap

  • 每个节点的压力过高,则该节点就可能触发 swap
  • swap 到的磁盘设备是共享的,不同的节点可能 swap 到一个设备上

shm

访存问题

访存问题主要是各种 cache miss、inter-core cache 的调优。

TLB miss

程序使用的是虚拟地址 Virtual Address,实际访问内存要用物理地址 Physical Address。虚拟地址到物理地址的映射存储在页表 Page Table 中。如果每次访问内存都要查页表就很很慢,因此 CPU 中增加了 TLB 来缓存最近的页表项。

需要考虑 TLB miss 的场景:

  • 大内存随机访问
    比如哈希表、图算法
  • 数据结构跨越多个页
    如 linked list
  • 频繁 context switch 导致 TLB flush
  • NUMA 架构下跨节点访问内存
    在 NUMA 系统中,每个 CPU 节点有自己的一套本地内存。
    当发生 TLB miss 时,CPU 会进行多级页表遍历,而这些页表可能也存在不同的 NUMA 节点中。
    当线程从 NUMA 节点 A 迁移到节点 B 时,会执行 TLB flush,因为 TLB 是 CPU 级别的。迁移后的所有地址都需要重新建立 TLB 映射。

可以使用 perf 采样 TLB miss 的情况。同理,eBPF 也支持。

1
perf stat -a -e dTLB-load-misses,iTLB-load-misses sleep 10

TLB Shootdown

Are You Sure You Want to Use MMAP in Your Database Management System?

内存问题诊断

如何诊断内存问题?

  • O11y 机制
  • heap profiling
  • mallctl 探查
  • valgrind 工具探查

内存错误

空指针

C++ 中的空指针影响会比较大。比如对 nullptr 调用 operator->() 就会得到一个 segfault,对应的 addr 可能漂到不知道哪里去了,很难定位问题。

特别地,C++ 还不太容易实现 Rust 中的 NotNull 指针,从而减少心智负担。这是因为 C++ 本身的移动语义会将移动后的对象的指针设置为空,而这就导致 NotNull 无法移动。而 unique_ptr 又是天生只支持移动的,这就导致了 NotNull 和 unique_ptr 无法兼容。Rust 能支持是因为编译期保证了使用移动后的对象一定是不能通过编译的。

一般有下面的一些做法:

  1. 使用 ASAN 进行检测。但这需要代码本身的 coverage 足够高,实际上要求有一个比较好的写单测或者做集成测试的习惯
  2. 使用线程池维护对象,避免使用任何形式的 shared_ptrunique_ptr 以及裸指针
  3. 使用一些 not_null ptr 的实现,这些实现能够 workaround 掉 unique_ptr 的相关问题
    https://github.com/bitwizeshift/not_null/blob/master/include/not_null.hpp 这样的库可以选择使用 check_not_null 在创建的时候执行运行期检查,也可以使用 assume_not_null 执行有限的编译期检查(但如果值在编译期无法确定,则编译期检查无效)。
    这个库也支持移动语义
    1
    2
    3
    4
    5
    6
    7
    8
    // Should never be null, but not yet refactored to be 'not_null'
    auto old_api(std::unique_ptr<Widget> p) -> void;

    auto new_api(cpp::not_null<std::unique_ptr<Widget>> p) -> void
    {
    // Extract the move-only unique_ptr, and push along to 'old_api'
    old_api(std::move(p).as_nullable());
    }

特别地,我不觉得 if likely(!ptr) throw_or_panic(); 这样的写法有太大问题,因为现代 CPU 的 speculation 机制让这个 if 的开销变得很低。但毫无疑问,每次都要判断,无疑加重了开发人员的心智负担,每一个函数的开头需要防御性编程写一堆 check。而实际上至少从某一层开始,工具函数就可以要求传入的 ptr 是 not null 了。特别地,对于一个全新 init 的工程,可以始终通过 std::optional<not_null> 来代替 std::shared_ptr

OOM

内存观测

线程级别的内存分配记录

详见C++ 内存监控方案

内存泄露

并不是野指针才算内存泄露。如果有一些结构存在于某些队列或者哈希表中,但并不是所有路径都会最终回收掉该结构,那么同样可以认为存在内存泄露。

访存问题

memory bound

观测:

Reference