文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

关于C++内存问题排查攻略

2024-11-29 19:43

关注

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

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

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

1. 静态分析

(1) 原理

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

(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

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

(3) 工具

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

注意事项:

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

2. 动态检测

(1) 通过proc文件系统

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

(2) 捕捉操作系统信号

原理:

工具:

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

注意事项:

libsigsegv是GPL协议

3. 查看coredump文件

重点关注:

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

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

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

1. GCC -fstack-protector

-fstack-protector的原理:

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

2. C11 Annex K (Bounds-checking interfaces)

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

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

3.  AddressSanitizer

详见4.1

4. Valgrind memcheck

详见4.2

三、内存泄漏:eBPF+火焰图,高效直观

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

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

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. 举个栗子

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

#include 
#include 
#include 
#include 
#include 

void LeakOnce(std::vector& 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 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

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

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

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

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

四、其他内存问题:AddressSanitizer为主,Valgrind memcheck为辅

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好太多,可以考虑使用线上节点排查问题。

2. Valgrind memcheck

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

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

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.

2. Valgrind helgrind/drd

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

3. AddressSanitizer仍然可用

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

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

六、低成本解决历史代码崩溃问题

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

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

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

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

七、解决偶发崩溃问题

问题出现: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,方便排查问题。

第二次尝试:

通过监控逐渐发现一些规律:崩溃集中在进程启动阶段,日常运行时很少。因此怀疑与进程启动时的状态或特定请求有关。

下一步是复现问题。在崩溃概率最高的地域,新建一个旁路 workload(两个节点),将北极星权重调为其他节点的 1/N,使用 API 定期重启旁路 workload 的 pod。经过几天,问题复现了!

backstrace与之前类似,找不出线索。那就上工具吧,能在线上使用的检测工具也就只有 AddressSanitizer了,编译一版部署到旁路 workload,继续定期重启,等待结果……

果然,断断续续出现了一些崩溃,但查看 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::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::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 >::deallocate(std::allocator&, 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, std::allocator >::_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, std::allocator >::_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, std::allocator >::_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, std::allocator >::_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, std::allocator >::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, std::allocator >::append(std::__cxx11::basic_string, std::allocator > 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, std::allocator >::operator+=(std::__cxx11::basic_string, std::allocator > 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::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::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 >::deallocate(std::allocator&, 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, std::allocator >::_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, std::allocator >::_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, std::allocator >::_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, std::allocator >::_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, std::allocator >::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, std::allocator >::append(std::__cxx11::basic_string, std::allocator > 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, std::allocator >::operator+=(std::__cxx11::basic_string, std::allocator > 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 问题……

再查看提交历史,发现多协程并发调用是在某个版本上线的,当时一切正常;上百个版本之后,调用流程中增加了这行问题代码。冗长膨胀的流程函数中新增一行代码很难引起注意,多人开发非常容易踩坑。

彻底解决问题需要从设计入手:重构流程,遵循单一职责,将修改集中到一处,便于检查;传参变成只读引用,消除 data race。

测试通过,上线,不再崩溃!

总结

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

来源:腾讯技术工程内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯