C++内存问题排查攻略

作者:johncchen

C++因其高性能仍然是许多关键应用的首选语言,但其复杂的内存管理也带来了诸多挑战。虽然使用现代C++能够有效解决大部分问题,但掌握常用的内存问题排查方法仍然十分必要,特别是在维护一些历史系统时。本文分为上下两篇:上篇(1~5)按照问题分类介绍和比较常用工具,下篇(6~7)通过两个具体案例展示这些工具的组合使用,希望能为读者带来有益的启发。笔者个人水平有限,文中难免存在疏漏之处,欢迎大家批评指正。

1. 栈溢出(stack-overflow):查看coredump文件为主,动态检测为辅

栈溢出的定位方法主要有静态分析、动态检测、查看coredump文件三种。

1.1 静态分析

1.1.1 原理

GCC提供了-fstack-usage选项,能输出每个函数栈的最大使用量。开启后,为每个编译目标创建.su文件,每行包括函数名、字节数、修饰符(static/dynamic/bounded)中的一个或多个。修饰符的含义如下:

  1. static: 堆栈使用量在编译时是已知的,不依赖于任何运行时条件。
  2. dynamic: 堆栈使用量依赖于运行时条件,例如递归调用或基于输入数据的条件分支。
  3. bounded: 堆栈使用量虽然依赖于运行时条件,但有一个可预知的上限。
1.1.2 举个栗子
void static_stack_usage() { int static_array[5]; }
void dynamic_stack_usage(int n) { int val[n]; }
int main() {
  static_stack_usage();
  int n = 10;
  dynamic_stack_usage(n);
  return 0;
}

g++ ./stack_test.cc -o stack_test -fstack-usage 

./stack_test.cc:2:6:void static_stack_usage() 16 static
./stack_test.cc:4:6:void dynamic_stack_usage(int) 48 dynamic
./stack_test.cc:6:5:int main() 32 static

疑问:看到这里,估计有小伙伴会问了:既然dynamic是不确定的,静态分析还有意义吗?其实,实际代码的.su一般是下面这种,dynamic和bounded组合在一起,虽然动态但有上限,因此可以计算出“最大”的栈用量。

xxbuild.cpp:277:5:int XXBuild::BuildPage() 528 dynamic,bounded

每个函数的栈使用量有了,如果知道函数的调用链就可以得出栈的最大使用量了。调用链可以从二进制文件中反汇编得到。

1.1.3 工具

静态分析常用于资源有限的嵌入式系统,常常集成在它们的开发工具中。但非嵌入式系统的这类工具比较少。开源的有 checkStackUsage等,收费的有stackanalyzer等。

注意事项:

若使用bazel编译,默认的沙箱模式会删除.su文件,因此编译时需要增加--spawn_strategy=standalone选项(非沙箱模式)

1.2 动态检测

1.2.1 通过proc文件系统

pmap或查看/proc/pid/maps中的stack,缺点是进程退出后就看不到了。

1.2.2 捕捉操作系统信号

原理:

  • 在 Unix-like 系统中,当程序执行非法内存访问时,操作系统会向该程序发送 SIGSEGV 信号(段错误)。默认情况下,接收到此信号的程序会终止。
  • 如果通过注册一个自定义的信号处理函数来拦截 SIGSEGV信号,处理函数会收到一个 siginfo_t 结构体,其中包含错误的地址和寄存器状态等上下文信息,可以判断是否发生了栈溢出。

工具:

libsigsegv-devel,可以定义自己的处理函数来响应内存访问错误,例如尝试恢复、记录错误信息或者优雅地关闭程序。

注意事项

libsigsegv是GPL协议

1.3 查看coredump文件

重点关注:

  • 层级是否过多,是否递归调用
  • 栈变量是否过大

修改栈(以及线程堆栈、协程堆栈)大小后测试。

2. 栈缓冲区溢出(stack-buffer-overflow):GCC -fstack-protector/C11 Annex K/AddressSanitizer

栈缓冲区溢出原因中很大一部分是数组索引/指针越界。在我看来,在项目中停止使用C风格的指针、使用STL容器能解决大部分问题。当然,一些项目处于维护状态,大规模改造未必合算,可以考虑使用以下工具。

2.1 GCC -fstack-protector

-fstack-protector的原理:

  1. 函数调用时,编译器在栈上分配一个随机生成的 canary 值(guard值),通常被放置在局部变量和控制数据(如返回地址)之间。
  2. 函数执行过程中,所有的局部变量操作都应当保持 canary 值不变。如果有缓冲区溢出,超出局部变量的数据可能会覆盖到 canary 值。
  3. 如果 canary 值被修改,程序会认为发生了栈溢出攻击,通常会立即终止,例如通过调用 __stack_chk_fail() 函数。

有不同的保护强度-fstack-protector/-fstack-protector-all/-fstack-protector-strong/-fstack-protector-explicit,一般-fstack-protector-strong即可。

2.2 C11 Annex K (Bounds-checking interfaces)

使用 C11 标准中引入的strncpy_s()等函数,比 strcpy()/strncpy() 等函数更安全。它要求指定源和目标的大小,并在复制过程中检查这些大小,以防止溢出。如果发生错误(如无效参数或目标太小),strncpy_s() 将设置 errno 并可以选择使程序失败。

较低版本的gcc不支持c11, 可以使用一些第三方实现,比如的openharmony的third_party_bounds_checking_function

2.3  AddressSanitizer

详见4.1

2.4 Valgrind memcheck

详见4.2

3. 内存泄漏:eBPF+火焰图,高效直观

3.1 Valgrind memcheck/AddressSanitizer/eBPF bcc-tools memleak比较

image.png

eBPF的最大的优点是“非侵入”,不需要重新编译或重启业务进程,对运行速度和内存用量的影响极小,可以忽略不计,可以线上使用。

3.2 eBPF bcc-tools memleak检测原理

eBPF程序是事件驱动的,在内核或应用经过特定钩子点(hook point)时运行。在memleak的源码中可以看到注册到了以下钩子点

attach_probes("malloc")
attach_probes("calloc")
attach_probes("realloc")
attach_probes("mmap", can_fail=True)  # failed on jemalloc
attach_probes("posix_memalign")
attach_probes("valloc", can_fail=True)  # failed on Android, is deprecated in libc.so from bionic directory
attach_probes("memalign")
attach_probes("pvalloc", can_fail=True)  # failed on Android, is deprecated in libc.so from bionic directory
attach_probes("aligned_alloc", can_fail=True)  # added in C11
attach_probes("free", need_uretprobe=False)
attach_probes("munmap", can_fail=True, need_uretprobe=False)  # failed on jemalloc

3.3 举个栗子

先写一段内存泄漏(不断增长)的测试代码

#include <iostream>
#include <chrono>
#include <thread>
#include <vector>
#include <string>

void LeakOnce(std::vector<std::string>& strs) {
    // Generate a random string
    std::string str;
    const std::string characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    for (int i = 0; i < 10; i++) {
        char randomChar = characters[rand() % characters.length()];
        str += randomChar;
    }
    strs.emplace_back(std::move(str));
}

void CallLeak(){
    std::vector<std::string> strs;
    while(true){
        LeakOnce(strs);
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

int main() {
    CallLeak();
    return 0;
}

g++ ./leak_test.cc -o leak_test --std=c++11 -g

检测结果如图,符合预期~

image.png

memleak具体选项详见-h,也可以参考官方例子。需要注意的是-O选项, attach to allocator functions in the specified object. 如果没有使用glibc而是使用jemlloc或tcmalloc,需要使用-O指定二进制文件(静态链接)或动态库(动态链接)。

3.4  改进memleak,支持火焰图

实际的内存泄漏经常是小规模、长时间的,会混杂在大量正常的内存申请和释放动作中,这时候memleak文本形式的输出就不够直观了。想到cpu性能调优经常用到的火焰图,如果memleak能生成直观的火焰图就好了。

火焰图的格式并不复杂,格式如下

[堆栈] [采样值]
main;foo;bar 76

PR4766有一个绘制火焰图的简单实现,没有合入主干很可惜。可以参考它,来修改已安装的bcc/tools/memleak。修改后执行:

/usr/share/bcc/tools/memleak2.py -p $(pgrep leak_test)  --report --report-file leak_test.stacks
flamegraph.pl --color=mem  --countname="bytes"< leak_test.stacks > leak_test.svg

image.png

在中大型项目中,火焰图能够很好地区分框架与业务模块的内存操作,便于逐级排查,非常清晰。

4.  其他内存问题:AddressSanitizer为主,Valgrind memcheck为辅

4.1 AddressSanitizer

编译和链接时加上-fsanitize=address,完整选项见AddressSanitizerFlags,一些常用选项如下:

  • export ASAN_OPTIONS="log_path=/my_path/asan:abort_on_error=1:disable_coredump=0:unmap_shadow_on_exit=1:debug=true:check_initialization_order=true:print_stats=true:strict_string_checks=true:dump_instruction_bytes=true"

AddressSanitizer会使程序运行慢约2倍,比Valgrind memcheck好太多,可以考虑使用线上节点排查问题。

4.2 Valgrind memcheck

运行速度慢10~50倍,消耗大量内存,可以通过关闭检查项目来提高速度、减少内存使用。

5. 多线程/协程的数据竞争(data race):ThreadSanitizer/Valgrind的helgrind和drd基本不可用,AddressSanitizer仍然可用

5.1 ThreadSanitizer

编译和链接增加-fsanitize=thread,编译通常遇到std::atomic_thread_fence报错,官方解释如下,好吧,std::atomic_thread_fence很常见,ThreadSanitizer基本不可用了。

-Wno-tsan Disable warnings about unsupported features in ThreadSanitizer. ThreadSanitizer does not support std::atomic_thread_fence and can report false positives.

除此之外,开启ThreadSanitizer对运行速度和内存消耗也有较大影响:

The cost of race detection varies by program, but for a typical program, memory usage may increase by 5-10x and execution time by 2-20x.

5.2 Valgrind helgrind/drd

比起ThreadSanitizer,需要消耗更多内存。我做了个测试,一个使用内存2.5G的服务,使用Valgrind helgrind或drd启动,32G内存都不够、直接OOM,因此在规模大些的项目中基本不可用。

5.3 AddressSanitizer仍然可用

AddressSanitizer不针对data race,但能检测内存异常。

下篇以排查某A服务内存问题的过程为例,演示上篇中工具的使用。其实,上篇的工具是下篇踩坑、填坑的经验总结。

6. 低成本解决历史代码崩溃问题

A 服务中有一大块老旧的业务逻辑,称之为模块 B,其特点如下:

  • 代码行数多, 2w+
  • 大量 C 风格字符串操作(如 strcpy 等),存在越界风险
  • 依赖大量老旧版本的第三方库
  • 需求很少,处于维护状态

问题出现:服务以前运行平稳,但从某天开始,线上节点隔三差五就会出现崩溃。查看 coredump 文件,发现崩溃在模块B的代码中, frame 0 中某些局部变量损坏。然而,重放崩溃前后一段时间内的请求并不能复现崩溃,应该是其他请求的栈缓冲区溢出,破坏了这条请求的栈。此类问题很难直接根据 coredump 文件定位。

排查过程:如 2.1 中所述,使用 -fstack-protector-strong 重新编译并上线,结果断断续续地因为 __stack_chk_fail 出现崩溃,这就好办了。按图索骥,发现是某些请求触发了历史 bug,导致一些局部变量指针越界,针对性地添加边界判断就修复了,从而以较小的代价解决了复杂历史代码的崩溃问题。

后续措施:考虑到模块 B 可能还有其他坑,一旦出现问题将导致 A 服务的节点崩溃,影响整体 SLA。因此将模块 B 拆分成独立的微服务 C。如果服务 A 调用服务 C 失败,可以走降级链路,从而提高业务整体的可用性。

7. 解决偶发崩溃问题

问题出现:A 服务频繁上线,经常在一周内发布三四个版本。某段时间内,崩溃的概率显著增加。查看 coredump 文件,发现经常崩溃在 STL 容器(如 std::map、std::unordered\_map、std::vector 等)中 std::allocator 的析构相关函数,但backstrace不确定,有时在这个模块中有时在那个模块中。重放崩溃前后一段时间内的请求无法复现崩溃,推测又是内存踩踏问题。

第一次尝试:逐一使用2.1 ~2.3的 GCC -fstack-protector /C11 Annex K/AddressSanitizer ,回放线上请求,结果都正常,这就尴尬了……

鉴于一时难以解决问题,首先采取措施确保线上稳定:

  • 将容器的健康检查方式从 TCP 改为 HTTP,这样在 core dump 开始而不是结束后就能检测出节点异常(core 文件约 20G,core dump 过程持续几分钟),尽早从北极星(服务注册与发现平台)上摘除,减少对线上的影响。这样线上可以继续开启coredump,方便排查问题。

第二次尝试:

  1. 通过监控逐渐发现一些规律:崩溃集中在进程启动阶段,日常运行时很少。因此怀疑与进程启动时的状态或特定请求有关。
  2. 下一步是复现问题。在崩溃概率最高的地域,新建一个旁路 workload(两个节点),将北极星权重调为其他节点的 1/N,使用 API 定期重启旁路 workload 的 pod。经过几天,问题复现了!
  3. backstrace与之前类似,找不出线索。那就上工具吧,能在线上使用的检测工具也就只有 AddressSanitizer了,编译一版部署到旁路 workload,继续定期重启,等待结果……
  4. 果然,断断续续出现了一些崩溃,但查看 coredump 文件的backstrace仍难以找到有效线索。有时崩溃在插件中,有时在 encode 过程中。咨询相关插件的同学,他们也感到很奇怪,没有思路。直到,直到,下面这个错误出现:
==181==ERROR: AddressSanitizer: attempting double-free on 0x61b000258480 in thread T14 (FiberWorker_02):
    #0 0x7f3a1f52a878 in operator delete(void*, unsigned long) ../../../../libsanitizer/asan/asan_new_delete.cpp:164
    #1 0x13d4f0c in std::__new_allocator<char>::deallocate(char*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/new_allocator.h:158
    #2 0x13d4f0c in std::allocator<char>::deallocate(char*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/allocator.h:200
    #3 0x13d4f0c in std::allocator_traits<std::allocator<char> >::deallocate(std::allocator<char>&, char*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/alloc_traits.h:496
    #4 0x13d4f0c in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_destroy(unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:300
    #5 0x13d4f0c in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_dispose() /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:294
    #6 0x13d4f0c in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_mutate(unsigned long, unsigned long, char const*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.tcc:338
    #7 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_append(char const*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.tcc:420
    #8 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::append(char const*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:1430
    #9 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::append(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:1396
    #10 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator+=(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:1338
    #11 0x1b91ac5 in construct_xx_query(thread_data*) xx/yy/zz/aa_util.cc:66
···
0x61b000258480 is located 0 bytes inside of 1539-byte region [0x61b000258480,0x61b000258a83)
freed by thread T13 (FiberWorker_01) here:
    #0 0x7f3a1f52a878 in operator delete(void*, unsigned long) ../../../../libsanitizer/asan/asan_new_delete.cpp:164
    #1 0x13d4f0c in std::__new_allocator<char>::deallocate(char*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/new_allocator.h:158
    #2 0x13d4f0c in std::allocator<char>::deallocate(char*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/allocator.h:200
    #3 0x13d4f0c in std::allocator_traits<std::allocator<char> >::deallocate(std::allocator<char>&, char*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/alloc_traits.h:496
    #4 0x13d4f0c in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_destroy(unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:300
    #5 0x13d4f0c in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_dispose() /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:294
    #6 0x13d4f0c in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_mutate(unsigned long, unsigned long, char const*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.tcc:338
    #7 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_append(char const*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.tcc:420
    #8 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::append(char const*, unsigned long) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:1430
    #9 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::append(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:1396
    #10 0x1b91ac5 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator+=(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) /usr/lib64/gcc/x86_64-pc-linux-gnu/12.3.0/../../../../include/c++/12.3.0/bits/basic_string.h:1338
    #11 0x1b91ac5 in construct_xx_query(thread_data*) xx/yy/zz/aa_util.cc:66
··· 

construct_xx_query(thread_data*) xx/yy/zz/aa_util.cc:66的代码是
thread_data->string_bb += judge_cc()

查看代码上下文,终于找到了原因!在某类请求中使用协程并发调用后端服务,而 thread_data->string_bb(std::string 类型)变量是唯一的,多个协程同时修改 thread_data->string_bb,导致 double-free!由于同时写入是小概率事件,所以崩溃是偶发的。原来是 data race 问题……

  1. 再查看提交历史,发现多协程并发调用是在某个版本上线的,当时一切正常;上百个版本之后,调用流程中增加了这行问题代码。冗长膨胀的流程函数中新增一行代码很难引起注意,多人开发非常容易踩坑。
  2. 彻底解决问题需要从设计入手:重构流程,遵循单一职责,将修改集中到一处,便于检查;传参变成只读引用,消除 data race。
  3. 测试通过,上线,不再崩溃!

总结:

大部分问题,尤其是难以排查的问题,应该在设计阶段就被解决掉,越往后代价越大。正所谓“善战者无赫赫之功”。

作者:腾讯程序员
文章来源:腾讯技术工程

推荐阅读

更多腾讯AI相关技术干货,请关注专栏腾讯技术工程 欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
8142
内容数
221
腾讯AI,物联网等相关技术干货,欢迎关注
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息