介绍 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.allocatedp
和 thread.deallocatedp
来注册。这样就可以知道每个线程分配或者释放了多少内存。
该⽅案能够很好看到内存分配和释放的速率和增量,例如如果观察到 allocated 斜率⼤幅增加,则说明该线程最近在⾼速分配内存。但难以判断某个线程到底 own 了多少内存,原因是:
- ⼀个线程可能释放另⼀个线程分配的内存。例如同一个模块中线程 bg-1 分配的内存可能被另⼀个线程 bg-2 释放。
- Rust 的 move 语义和协程机制会加剧这个问题。
Jemalloc Arena
该⽅案可以看做是对 allocatedptr 的补充。通过 thread.arena 绑定⼀个线程到某个 arena,则可以通过该 arena 获知该线程 own 了多少内存,所以这是看存量的⼯具。
但线程 own 多少内存,并不等于某个模块占⽤了多少内存。原因:
- 内存的 ownership 会在平⾏的模块之间转移。例如 P 模块分配出来的内存,可能会被转移给 S 模块。所以即便能够看到 S 线程池对应的 arena 的占⽤上升,也难以判断是 S 模块的原因,还是 P 转移过来的内存。
- 上级模块从同线程中调⽤下级模块,⽆法区分出上级和下级分别消耗了多少内存。
这个⽅案有下述的缺点:
- 我们更需要找内存增⻓的根因,知道内存都在哪些 arena ⾥⾯未必是⾜够的。
- 一些使用线程池的模块中的内存分配⼤致满⾜“⾃⼰⽤⾃⼰弃”的 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 去线程启动的事件。
缺点:
- 只能根据调⽤链路细化,不能追踪某个组件占据了多少内存。
Jemalloc mallocx
通过 MALLOCX_ARENA flag,可以在 mallocx 的时候指定从某个 arena 分配。因此对于模块 A 可以替换它的所有 malloc 为 mallocx,从⽽实现追踪该模块的内存分配。
C++ 中内存管理层级和⽅式都很多,不能通过简单替换 mallocx 才能做到按组件统计。
C++ 的堆内存管理层级
C++ Custom Allocator
对于 stl 中的 container 类型提供,指⽰如何构造 Container<T, CustomAllocator<T>>
。因为⼤部分内存的占⽤都是通过 C++ 的容器对⼀些基本类型组合产⽣的,因此通过指定⾃⼰的 allocator 可以达到较⾼的覆盖率。
缺点:
- 只对 stl 起作⽤,custom class 需要⾃⼰适配,并且会传染。
- Stl 的接⼝也不同,诸如 std::map、std::vector 需要提供⼀个额外的参数。⽽ std::make_shared
需要被 std::allocated_shared 代替。修改成本⽐较⼤。 - Allocator 是有类型的,所以不同 allocator 的容器之间不能简单实现互操作,除⾮使⽤ pmr。
pmr
需要⽤ std::pmr 下⾯的容器,同样具有传染性。
C++ Custom global operator new
可以通过下⾯实现⼀些类似 “thread_local memory tracker” 的功能,避免掉⼿动埋点。
- 在 new 或者 delete 中调⽤ backtrace 获得前⼀帧,判断组件来源
- 设法 inline 这些 operator,然后给需要监控的
__FUNCTION__
加上特定的前缀
1 | inline __attribute__((always_inline)) void *operator new(size_t size) |
缺点:
- 在 critical path 上,打击范围太⼴。C++ 实践中不太推荐这么做。
C++ Custom class operator new
相⽐ global operator new 的⽅案,class operator new 的时候已经知道了对象的类型,所以打击范围不⼴。我们可以仅仅针对某些对象统计。
缺点:
- 只能追踪通过 new 分配的内存。栈内存⼀般较⼩,所以这⼀点不是问题。
- 在对象内部再通过 new 分配的动态内存⽆法被追踪。也就意味着
std::vector::push_back 、 std::make_shared 和 new T[] 这样的主⼒内存分配点⽆法被跟踪。
因此,基于这样的⽅案,需要在某个 T 提供⼀个 size() ⽅法,然后 hook 住 T 的 operator new,从⽽给到关于 T 的总内存占⽤的统计。例如对 Block 和 VersionedPageEntries 提供统计。
缺点:
- std::make_shared 直接调⽤ ::new,因此对此没有作⽤