C++ 内存监控方案

介绍 C++ 上的内存监控方案。

默认使用 jemalloc。

MemoryTracker

原理是每次分配内存的时候,manually 去向⼀个 MemoryTracker 注册。下层级的 Tracker 和上层级的 Tracker ⼀起组成树状结构。

prof.dump

可以定期通过 prof.dump 的方式 dump 下堆文件。
需要配置 MALLOC_CONF 为 prof:true,否则会报错 Resource temporarily unavailable。可以设置 prof.activate:false 避免在不需要 profile 的时候,产生开销。

注意,prof:true 是不可以运行期修改的。但是 prof.activate 是可以的。

Jemalloc allocatedptr

这个方案要求感知线程的创建和销毁,在对应的时候,通过 thread.allocatedpthread.deallocatedp 来注册。这样就可以知道每个线程分配或者释放了多少内存。
该⽅案能够很好看到内存分配和释放的速率和增量,例如如果观察到 allocated 斜率⼤幅增加,则说明该线程最近在⾼速分配内存。但难以判断某个线程到底 own 了多少内存,原因是:

  1. ⼀个线程可能释放另⼀个线程分配的内存。例如同一个模块中线程 bg-1 分配的内存可能被另⼀个线程 bg-2 释放。
  2. Rust 的 move 语义和协程机制会加剧这个问题。

Jemalloc Arena

该⽅案可以看做是对 allocatedptr 的补充。通过 thread.arena 绑定⼀个线程到某个 arena,则可以通过该 arena 获知该线程 own 了多少内存,所以这是看存量的⼯具。
但线程 own 多少内存,并不等于某个模块占⽤了多少内存。原因:

  1. 内存的 ownership 会在平⾏的模块之间转移。例如 P 模块分配出来的内存,可能会被转移给 S 模块。所以即便能够看到 S 线程池对应的 arena 的占⽤上升,也难以判断是 S 模块的原因,还是 P 转移过来的内存。
  2. 上级模块从同线程中调⽤下级模块,⽆法区分出上级和下级分别消耗了多少内存。

这个⽅案有下述的缺点:

  1. 我们更需要找内存增⻓的根因,知道内存都在哪些 arena ⾥⾯未必是⾜够的。
  2. 一些使用线程池的模块中的内存分配⼤致满⾜“⾃⼰⽤⾃⼰弃”的 pattern,因此可以通过减法来算出存量。因此,“Jemalloc Arena” 相⽐ “Jemalloc allocatedptr” 的⽅案的作⽤不是很⼤

thread_local memory tracker

来⾃ Doris 的 Memtracker ⽅案。
该⽅案可以作为 “Jemalloc allocatedptr” ⽅案的补充。对于“上级模块从同线程中调⽤下级模块”的情况,可以使⽤⼀个 thread_local 变量记录栈中的⼀部分的内存开销。如下所⽰ kvs_page_mem 这个 thread_local 变量记录了 thread 1 中从 KVStore 调⽤到 PageStorage ⼀部分的开销。因此再结合 “Jemalloc allocatedptr” ⽅案本⾝的数据,就可以区分开来 PageStorage 产⽣
的内存(如新建⽴的 PageDirectoy)和调⽤链路上其他的内存,如 KVStore 和 PageStorage 中缓存的其他内容。
thread_local 的信息会被定时地上报给全局的 tracker,并由 tracker 做聚合后上报给 Prometheus。
也可以直接复⽤当前的逻辑,由 tracker 直接做聚合,但这样就需要⼀个全局的 hook 去线程启动的事件。

缺点:

  1. 只能根据调⽤链路细化,不能追踪某个组件占据了多少内存。

Jemalloc mallocx

通过 MALLOCX_ARENA flag,可以在 mallocx 的时候指定从某个 arena 分配。因此对于模块 A 可以替换它的所有 malloc 为 mallocx,从⽽实现追踪该模块的内存分配。
C++ 中内存管理层级和⽅式都很多,不能通过简单替换 mallocx 才能做到按组件统计。

C++ 的堆内存管理层级

C++ Custom Allocator

对于 stl 中的 container 类型提供,指⽰如何构造 Container<T, CustomAllocator<T>> 。因为⼤部分内存的占⽤都是通过 C++ 的容器对⼀些基本类型组合产⽣的,因此通过指定⾃⼰的 allocator 可以达到较⾼的覆盖率。
缺点:

  1. 只对 stl 起作⽤,custom class 需要⾃⼰适配,并且会传染。
  2. Stl 的接⼝也不同,诸如 std::map、std::vector 需要提供⼀个额外的参数。⽽ std::make_shared
    需要被 std::allocated_shared 代替。修改成本⽐较⼤。
  3. Allocator 是有类型的,所以不同 allocator 的容器之间不能简单实现互操作,除⾮使⽤ pmr。

pmr

需要⽤ std::pmr 下⾯的容器,同样具有传染性。

C++ Custom global operator new

可以通过下⾯实现⼀些类似 “thread_local memory tracker” 的功能,避免掉⼿动埋点。

  1. 在 new 或者 delete 中调⽤ backtrace 获得前⼀帧,判断组件来源
  2. 设法 inline 这些 operator,然后给需要监控的 __FUNCTION__ 加上特定的前缀
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
inline __attribute__((always_inline)) void *operator new(size_t size)
noexcept(false) {
return wrap_malloc(size, __FILE__, __LINE__, __FUNCTION__);
}
inline __attribute__((always_inline)) void *operator new[](size_t size)
noexcept(false) {
return wrap_malloc(size, __FILE__, __LINE__, __FUNCTION__);
}
inline __attribute__((always_inline)) void operator delete(void *ptr) noexcept
{
wrap_free(ptr, __FILE__, __LINE__, __FUNCTION__);
}
inline __attribute__((always_inline)) void operator delete[](void *ptr)
noexcept {
wrap_free(ptr, __FILE__, __LINE__, __FUNCTION__);
}

缺点:

  1. 在 critical path 上,打击范围太⼴。C++ 实践中不太推荐这么做。

C++ Custom class operator new

相⽐ global operator new 的⽅案,class operator new 的时候已经知道了对象的类型,所以打击范围不⼴。我们可以仅仅针对某些对象统计。
缺点:

  1. 只能追踪通过 new 分配的内存。栈内存⼀般较⼩,所以这⼀点不是问题。
  2. 在对象内部再通过 new 分配的动态内存⽆法被追踪。也就意味着

std::vector::push_back 、 std::make_shared 和 new T[] 这样的主⼒内存分配点⽆法被跟踪。
因此,基于这样的⽅案,需要在某个 T 提供⼀个 size() ⽅法,然后 hook 住 T 的 operator new,从⽽给到关于 T 的总内存占⽤的统计。例如对 Block 和 VersionedPageEntries 提供统计。
缺点:

  1. std::make_shared 直接调⽤ ::new,因此对此没有作⽤