内存领域知识

来自:

术语

  • VA (Virtual Address):进程虚拟地址空间中的一个字节位置
  • PA (Physical Address):实际物理地址空间中的一个字节位置
  • VMA (Virtual Memory Area):描述连续用户虚拟地址区间及访问规则的内核对象
  • PTE (Page Table Entry):末级页表项,包含页帧、权限或软件特殊状态
  • PFN (Page Frame Number):物理页帧号,不包含页内偏移

下面是几个物理内存相关的指标:

  • RSS (Resident Set Size)
    RSS 可以粗略理解为“该进程当前驻留在物理内存中的页面总量”,但它是进程视角的记账值,不等于进程独占的物理内存,也不等于系统实际能释放多少内存。
    例如,同一个 4KB 文件页被两个进程映射:
    • 两个进程的 RSS 都可能各增加 4KB;
    • 系统实际只有一个 4KB 物理页;
    • 一个进程 munmap 后,其 RSS 减少 4KB;
    • 但物理页仍可能被另一个进程使用,或者留在 page cache 中。
      因此:
      RSS 下降 = 该物理页不再计入这个进程的驻留集。
      RSS 下降 ≠ 对应物理页一定已经归还给空闲内存。
      对于非 SHARED 的 ANONYMOUS,munmap 导致的 RSS 下降通常确实对应物理页被释放;但对文件页、共享页、fork 后的 CoW 页就不能这样推断。若关心共享后的真实占用,可以结合看 PSS。
  • PSS 按比例分担共享库的物理内存
  • USS 不包含共享库使用的物理内存

mmap 系列

mmap

mmap 负责分配连续的虚拟地址区间 VMA。

1
2
3
4
5
void *mmap(size_t length;
void addr[length], size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(size_t length;
void addr[length], size_t length);

几个参数:

  • addr:是个 VA,NULL 让内核选择,非 NULL 通常只是提示
    不一定能满足的原因是:
    • mmap_min_addr
    • [addr, addr + length) 与堆、栈、动态库或已有 mmap 的 VMA 重叠
    • 对齐不满足,不对齐 4KB 或者更大的页(HugeTLB)
    • 不正确的地址范围
  • prot:PROT_NONE/READ/WRITE/EXEC
  • offset:映射从文件的哪个字节开始,普通映射要求是系统页大小的整数倍

几个 flag:

  • MAP_SHARED
    对其他进程可见。

  • MAP_SHARED_VALIDATE

  • MAP_PRIVATE
    创建一个 private 的 COW mapping,即写时复制。如果希望能真正双向共享,需要用 MAP_SHARED。
    It is unspecified whether changes made to the file after the mmap() call are visible in the mapped region.

  • MAP_FIXED_NOREPLACE
    类似于 MAP_FIXED,但是如果 overlap 了,会 EEXIST。

  • MAP_FIXED
    直接在 addr 分配,而不是将 addr 作为 hint。addr 必须对齐。
    If the memory region specified by addr and length overlaps pages of any existing mapping(s), then the overlapped part of the existing mapping(s) will be discarded. If the specified address cannot be used, mmap() will fail.
    下面展示了一个 pitfall

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    时刻1: 线程A 读 /proc/pid/maps
    → 发现地址范围 [X, Y) 空闲,决定稍后映射它

    时刻2: 线程B 做了一件内部会分配内存的事
    → 比如 dlopen() 加载一个 .so
    → 内核(或用随机地址的 mmap)恰好选了 [X, Y) 的一部分,把它占了

    时刻3: 线程A 调用 mmap(addr=X, MAP_FIXED)
    → 内核发现 [X, Y) 已被线程B的映射占用
    → MAP_FIXED 的语义是"强制覆盖",内核直接替换了线程B的映射
    → 线程B的内存被摧毁,程序崩溃或数据损坏
  • MAP_ANONYMOUS
    匿名映射,拿到的全 0 的一段内存。一般要求 fd 为 -1。offset 一般为 0。

  • MAP_POPULATE
    会尝试预先触发部分缺页,降低后续首次访问延迟

  • MAP_NONBLOCK
    One day, the combination of MAP_POPULATE and MAP_NONBLOCK may be reimplemented.

  • MAP_LOCKED
    Mark the mapped region to be locked in the same way as mlock.

常见用法

1
2
3
4
5
6
7
/* private anonymous memory */
p = mmap(NULL, n, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
/* private file view: writes use copy-on-write */
p = mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, off);
/* shared file view: writes can dirty the file page cache */
p = mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_SHARED, fd, off);

注意点:

  • MAP_ANONYMOUS 也可以和 MAP_SHARED 一起使用
    一种是类似于 shm。
    另一种如下,相当于通过 fork 来实现共享。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
    MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    pid_t pid = fork();
    if (pid == 0) {
    *p = 123;
    _exit(0);
    }
    waitpid(pid, NULL, 0); /* synchronization */
    printf("%d\n", *p); /* prints 123 */
  • 对于具名映射,MAP_SHARED 修改通常先进入 page cache。其他映射看到修改与数据何时落到持久介质是两个问题;后者涉及 writeback、msync、fsync 和存储设备语

持久化、内存释放

匿名映射

内存紧张时:

  • 如果有 swap,则:
    • 内核会对匿名页做 swap-out:写入 swap 区并释放物理页
    • 虚拟地址仍然存在,页表项变成 swap entry
  • 否则无法回收,只能 OOM

用户调用 madvise 或者 munmap:

  • 根据 MADV_DONTNEED / MADV_FREE 或者 mmap 则释放时机不同

具名映射

文件映射物理页的回收规则与匿名页不同,文件页属于 page cache:

  1. 只读文件映射 (PROT_READ) 或 shared clean pages
    可以随时被内核回收(因为内容可以从文件重新加载)。
    在内存不足时,文件页会被优先回收。
  2. dirty shared pages(MAP_SHARED + write)
    这些属于 dirty file-backed pages。
    必须先执行 writeback 将这些 page flush 到文件,然后才能回收,称为 drop clean page。

注意 fd 在 mmap 成功后可以关闭。映射持有底层文件对象的引用,因此 close(fd) 不等于 munmap。

mlock

把一部分 VMA pin 在物理内存里,防止它们被换到 swap 里面。

1
2
3
4
5
6
7
8
9
int mlock(size_t size;
const void addr[size], size_t size);
int mlock2(size_t size;
const void addr[size], size_t size, unsigned int flags);
int munlock(size_t size;
const void addr[size], size_t size);

int mlockall(int flags);
int munlockall(void);

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 | 删除 VMA,释放虚拟地址,RSS 下降;物理页在没有其他引用时被释放 | 删除 VMA,RSS 下降;文件页仍可能保留在 page cache 中 |
| MADV_DONTNEED | 保留 VMA,立即降低 RSS 并丢弃页面内容;再次访问时得到零页 | 保留 VMA 并立即降低 RSS;page cache 中的物理页未必被释放,再次访问时重新触发 page fault |
| MADV_FREE | 保留 VMA,将页面标记为可回收;RSS 通常要等到实际回收后才下降。回收前可能仍能读到旧内容,重新写入会取消该页的回收状态 | 不支持;MADV_FREE 只适用于私有匿名映射 |

注意, munmap 和 MADV_DONTNEED 都不能保证文件脏页已经持久化;需要持久化时,应使用 msync、fsync 等接口。

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

mmap 的实现原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
user mmap()
-> libc wrapper
-> architecture syscall entry
-> __x64_sys_mmap() / sys_mmap_pgoff() [representative]
-> ksys_mmap_pgoff()
-> fget(fd) when file-backed
-> setup explicit hugetlb object when requested
-> vm_mmap_pgoff()
-> security checks
-> mmap_write_lock(mm)
-> do_mmap()
-> choose address / validate flags and permissions
-> mmap_region()
-> merge or create VMA
-> file or driver mmap preparation
-> install VMA metadata
-> mmap_write_unlock(mm)
-> optional mm_populate()
-> return mapped address to userspace
  1. ksys_mmap_pgoff
    对 vm_mmap_pgoff 封装。如果在 perf 看到大量的 ksys_mmap_pgoff,则应该考虑

    1
    2
    3
    4
    5
    6
    7
    8
    频繁 mmap/munmap
    ↓
    mmap_lock 竞争
    VMA 查找/插入/merge
    文件系统 ->mmap()
    MAP_POPULATE
    页表建立
    TLB shootdown
  2. vm_mmap_pgoff

  3. __get_unmapped_area
    寻找一段合适的 [addr, addr+length)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    high address
    +------------------+
    | stack |
    | |
    | mmap area | ← 大量 mmap 通常从这里 top-down 分配
    | |
    | shared libraries |
    | |
    | heap |
    | executable |
    +------------------+
    low address
  4. mmap_region 真正修改 mm_struct 的 VMA topology

  5. mmap_write_lock
    Linux 用 Maple Tree 来管理 VMA。
    这个锁是进程粒度的。

  6. VMA merge

    1
    2
    3
    4
    5
    6
    7
    0x1000 -------- 0x5000
    RW anonymous private
    0x5000 -------- 0x9000
    RW anonymous private
    可以 merge 为
    0x1000 ---------------- 0x9000
    one VMA

特别地,MAP_SHARED | MAP_ANONYMOUS 会直接用 shmem。

mmap 的性能

比较集中常见的读写模型的延迟,可以看出 mmap 的优势主要是少了一次拷贝,并且少了诸如 read 这样的系统调用。劣势主要在 page cache 方面,包括无法 exploit 多个 SSD 的性能,或者频繁的 page cache 回收给内核的 page fault、kswapd 带来压力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mmap 热访问
≈ CPU load + cache/TLB
+ 首次访问可能发生的 minor fault

mmap 冷访问
≈ major fault + VM/page-cache 路径
+ NVMe I/O + 页表建立 + TLB
+ 可能的预读、回收和写回

buffered read/pread
≈ syscall + page-cache 查找 + 一次内核到用户拷贝
+ 必要时的 NVMe I/O

io_uring/O_DIRECT
≈ 批量提交/完成 + NVMe I/O + DMA
+ 应用自己的缓存管理

基于此场景的判断:

场景 对 mmap 的判断
RO + Random + 常驻 最优选
多进程共享 RO 通常很合适,物理页可以在进程间共享
冷数据顺序扫描 不应默认选择 mmap;大块 read、pread 或 buffered io_uring 往往更容易跑满带宽
冷数据随机读取,追求最高 NVMe IOPS Page-fault 驱动不利于主动维持较深的 I/O 队列,应优先测试异步 I/O
工作集明显大于 RAM 风险较高;页面回收、反复 fault 和读放大可能造成严重尾延迟
高并发或使用多块 SSD Page-fault 路径及内核同步点可能成为扩展性瓶颈(详见《Are You Sure You Want to Use MMAP in Your Database Management System》)
事务 需要明确持久化边界,性能和正确性都较难控制;显式缓冲池、WAL、pwrite/fdatasync 通常更合适
access pattern 明确且可 prefetch mmap 配合 madvise 可以改善表现,但需要实测
数据只访问一次 mmap 优势有限,可能产生不必要的页表和缺页处理开销

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 来获得直接的内存地址,找不到再查页表。

关于透明大页

使用透明大页的缺点:

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

物理内存用途

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

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

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

内核内存分配

PageCache 和缺页中断

缺页中断的顺序:

  • TLB miss
  • 找不到 PTE
  • 触发缺页中断
  • 内核定位 VMA

两种 pagefault 的处理:

  • minor:通常不需要等待存储 I/O,例如目标页已经在 page cache 中、只需建立 PTE,或完成普通匿名页映射。
  • major:需要等待磁盘、网络文件系统或其他后端 I/O 把数据带入内存,延迟通常明显更高。

伙伴系统和 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)

kmalloc 和 vmalloc

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

常见用法如下

1
2
3
4
5
6
int fd = shm_open("/example", O_CREAT | O_RDWR, 0600);
if (fd == -1) /* handle error */;
if (ftruncate(fd, 4096) == -1) /* handle error */;
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (p == MAP_FAILED) /* handle error */;

注意这里 ftruncate 把底层对象的逻辑长度从 0 扩大为 4096 字节。这个要和 mmap 的 length 区分开来。

访存问题

访存问题主要是各种 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
  • malloctl 探查
  • valgrind 工具探查
  • eBPF 探查 mm 相关系统调用
  • 一些工具如 pmap 等

内存错误

空指针

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

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

一般有下面的一些做法:

  1. 使用 ASAN 进行检测。但这需要代码本身的 coverage 足够高,实际上要求有一个比较好的写单测或者做集成测试的习惯
  2. 使用线程池维护对象,避免使用任何形式的 shared_ptr、unique_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

Linux 的 overcommit 机制

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

内存观测

线程级别的内存分配记录

详见C++ 内存监控方案

内存泄露

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

访存问题

memory bound

观测:

Reference