1. 背景

1.1. 接手老系统

最近我们又接手了一套老系统,老系统的迭代效率和稳定性较差,我们打算做重构改造,但重构周期较长,在改造完成之前还有大量的需求迭代。因此我们打算先从稳定性和迭代效率出发做一些微小的升级,其中一项效率提升便是升级编译工具 和 GCC 版本。 老系统使用 Autotools 编译工具链,而我们新服务通常采用 bazel,bazel 在构建速度、依赖描述、工具链等方面有很大优势。我们决定将老系统的编译工具迁移到 bazel,同时也从 GCC4 升级到 GCC8。

1.2. 升级 bazel 和 GCC8

老系统经过多年的迭代,其依赖关系有大量的冗余,经过数天的处理,最终我们梳理出干净准确的依赖关系图,并升级为 bazel + GCC8。其中部分迭代较少的老仓库,采用 bazel 的 configure_make 工具引入,迭代较多的仓库则直接用 bazel 改造。在完成老系统全链路服务的改造之后,我们发现其中一个服务出现了内存泄漏。

2. 内存泄漏现象

2.1. 发现内存泄漏

内存泄漏出现在一个名为 Xxx 的服务上,它负责做图片 CPU 特征计算并将结果写入 HBase,是一个多进程服务,一个进程通常使用 7G 左右的内存,而内存泄漏的时候,流量高峰期半小时可以涨到 20G+。

2.2. 定位到泄漏版本和临时规避措施

首先,我们调查近期修改的版本,发现是升级 bazel 和 GCC8 引入的,这次修改代码量较多。

但仔细分析代码,发现多半是一些 namespace、include 之类的编译错误修改,没有改动业务逻辑,从代码修改上看不出来有内存泄漏。然后,经过一系列的调查和尝试,我们发现使用 bazel 和 GCC4 不会有内存泄漏,因此我们临时将主干代码降级到 GCC4,优先解决线上问题。

3. 内存泄漏原因和避开方法

通过降级到 GCC4 解决了线上内存泄漏,但这不是治本的方法,我们通过层层深入,终于将问题分析清楚并在 GCC8 下解决,下面对结论做简要说明。

3.1. 这是 GCC8 O1~O3 编译优化的 BUG

通过 jemalloc、代码日志、GDB 等工具和手段,发现代码有异常抛出的某种场景下,编译器为异常堆栈展开代码做了不合适的性能优化,使得引用计数对象析构时,没有对计数减一,导致内存无法释放。 触发编译器 BUG 的示例代码:

/**
* @file bug_example.cc
* @brief GCC8 编译器优化导致内存泄漏的示例代码
* O1\O2\O3 都会触发 bug
* g++ bug_example.cc -O3 -g -std=c++17 -o bug_example.out
*
* 使用 5.x 版本的 jemalloc 验证:
* 1、编译:g++ bug_example.cc -O3 -g -std=c++17 -L./jemalloc/lib -ljemalloc -ldl -lpthread -o bug_example.out
* 2、运行:MALLOC_CONF=prof_leak:true,lg_prof_sample:0,prof_final:true ./bug_example.out
*/ #include <iostream>
#include <stdexcept>
#include <string>
#include <vector> // 为方便介绍,简化掉侵入式智能指针的部分代码
// 引用计数
class Counted {
public:
virtual ~Counted() = default;
Counted* retain() {
++count_;
return this;
}
void release() {
if (--count_ == 0) {
count_ = 0xDEADF001; // 去掉这一行可以解决 GCC8 编译器优化 BUG
// 加一个日志打印,也可以解决 GCC8 编译器优化 BUG
// std::cerr << "delete Counted,this=" << this << std::endl;
delete this;
}
} private:
unsigned int count_ = 0;
}; // 智能指针模板
template <typename T>
class Ref {
public:
explicit Ref(T* obj = nullptr) { reset(obj); }
Ref(const Ref<T>& other) { reset(other.object_); } ~Ref() {
if (object_ != nullptr) {
object_->release();
}
} void reset(T* obj) {
if (obj != nullptr) {
obj->retain();
}
if (object_ != nullptr) {
object_->release();
}
object_ = obj;
} private:
T* object_ = nullptr;
}; // 业务类型
class MyType : public Counted {
public:
MyType() {
for (int i = 0; i != 10000; ++i) {
something_.emplace_back(std::to_string(i));
}
std::cerr << __FUNCTION__ << std::endl;
}
~MyType() { std::cerr << __FUNCTION__ << std::endl; } private:
std::vector<std::string> something_;
}; // 包了两层智能指针对象之后,在有异常时,会触发 GCC8 编译器 BUG
// 注:如果智能指针采用 const&,不会触发 BUG
void Exception_FuncWrapperLevel2(Ref<MyType> obj) { throw std::runtime_error("my exception..."); }
void Exception_FuncWrapperLevel1(Ref<MyType> obj) { Exception_FuncWrapperLevel2(obj); }
void RunWithExceptionUnwind() {
try {
Ref<MyType> obj(new MyType);
Exception_FuncWrapperLevel1(obj);
} catch (const std::exception& e) {
std::cerr << "catch exception=" << e.what() << std::endl;
}
} // 正常调用,不会触发 BUG
void Normal_FuncWrapperLevel2(Ref<MyType> obj) {}
void Normal_FuncWrapperLevel1(Ref<MyType> obj) { Normal_FuncWrapperLevel2(obj); }
int RunNormal() {
try {
Ref<MyType> obj(new MyType);
Normal_FuncWrapperLevel1(obj);
} catch (const std::exception& e) {
std::cerr << e.what() << std::endl;
}
return 0;
} int main() {
std::cerr << "----bug call----start" << std::endl;
RunWithExceptionUnwind();
std::cerr << "----normal call----start" << std::endl;
RunNormal();
} /*
输出:
----bug call----start
MyType
catch exception=my exception...
----normal call----start
MyType
~MyType
*/

错误编译优化后的汇编代码:

3.2. BUG 的避开方法

如示例代码注释所述,在引用计数的析构函数中加一行日志,或者去掉对 count_ 的赋值,或者使用 const& 传参,都可以阻止编译器优化。甚至可以将编译优化去掉,使用 O0 做编译,也能解决。

  void release() {
if (--count_ == 0) {
count_ = 0xDEADF001; // 去掉这一行可以解决 GCC8 编译器优化 BUG
// 加一个日志打印,也可以解决 GCC8 编译器优化 BUG
// std::cerr << "delete Counted,this=" << this << std::endl;
delete this;
}
}

3.3. 常见的编程指南也能帮助我们避开 BUG

如果我们遵循常见的编程指南,也能避开这个 BUG。具体包括以下常见代码实践:

  • 减少对象的隐藏拷贝。在传递引用计数对象时,可以使用 const&,消除 Ref 对象的拷贝。
  • 使用成熟的库,避免重造轮子。可以使用 std::enable_shared_from_this 和 std::shared_ptr 来代替自定义的侵入式智能指针。
  • 慎重使用异常。异常有很多注意事项,譬如要和 RAII 配合,要考虑是否影响性能等,对开发者的能力有较高要求,因此很多项目都禁用异常。

3.4. 升级到 GCC 新版本

升级到 GCC9+ 的版本也可以解决该 BUG。

4.内存泄漏定位的经验分享

本章对内存泄漏定位过程做详细介绍,方便想复用调查经验或者想了解调查过程的同事。

4.1. 使用 jemalloc 定位问题函数

jemalloc 自带的内存分析工具功能强大,效率极高,推荐使用。

(1)源码编译 jemalloc,并开启 --enable-prof 编译选项。

WORKSPACE:

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
name = "rules_foreign_cc",
strip_prefix = "rules_foreign_cc-0.10.1",
url = "https://github.com/bazelbuild/rules_foreign_cc/archive/0.10.1.tar.gz",
) load("@rules_foreign_cc//foreign_cc:repositories.bzl", "rules_foreign_cc_dependencies")
rules_foreign_cc_dependencies() all_content = """filegroup(
name = "all",
srcs = glob(["**"]),
visibility = ["//visibility:public"]
)
""" http_archive(
name = "jemalloc",
build_file_content = all_content,
strip_prefix = "jemalloc-5.3.0",
urls = ["https://github.com/jemalloc/jemalloc/archive/refs/tags/5.3.0.tar.gz"],
)

BUILD:

configure_make(
name = "jemalloc",
autoconf = True,
autoconf_options = ["-i"],
configure_in_place = True,
configure_options = ["--enable-prof"],
lib_source = "@jemalloc//:all",
targets = ["-j12", "install"],
out_include_dir = "include",
out_lib_dir = "lib",
out_static_libs = [
"libjemalloc.a",
],
)

注:编译产物还有 jeprof,可以用于分析内存分配情况,拷贝出来备用。

(2)rinet 使用自己编译的 jemalloc,并开启定期 dump 堆分配信息。

# 开启性能分析,并每新增 1G 内存 dump 内存分配信息
export MALLOC_CONF="prof:true,lg_prof_interval:30" ./rinet config.conf

(3)对比两次堆分配信息,确认内存泄漏函数

首先,生成 pdf:

./jeprof  --show_bytes --pdf a.out jeprof.1.0.f.heap > a.pdf
./jeprof --show_bytes --pdf a.out jeprof.2.0.f.heap > b.pdf

然后,对比 a、b 两次内存分配图,可以看到在 Xxxx 类的 read_mem 函数里出现内存泄漏。

结合 pdf 的提示,找到具体代码中的位置。

4.2. 模拟现场确认 BUG 的普遍性

(1)进一步查看源码,确认这是一个侵入式智能指针,即引用计数挂在业务类型上,类似使用 std::enable_shared_from_this。通过添加日志,确认在 decode() 函数里抛出异常后,引用计数出错。

(2)引用计数+两层函数调用+抛出异常模拟

见本文第三章的示例代码,通过该代码的模拟可以复现 BUG,确认该 BUG 具有普遍性。同时测试也显示,业务代码中加一行日志代码,可以修复该 BUG:

4.3. 使用 GDB 调试确认问题指令

为什么引用计数会出错,是析构函数没有调用,还是其他原因?GDB 定位到具体位置:

常用指令如下:

  • 启动:gdb ./a.out
  • 在析构函数代码附近打断点:break main.cc: 28
  • 显示汇编指令:layout asm
  • 单步执行汇编:si、ni

4.4. GCC 社区有类似的异常堆栈展开 BUG 反馈

optimized code does not call destructor while unwinding after exception

这个 BUG 在 函数带有 throw(int) 描述时,才会触发。实测显示:

  • GCC4.8.5 无 BUG
  • GCC8.3.1 有 BUG
  • GCC12.2.0 无 BUG

但社区反馈的这个 BUG 和本文涉及的 BUG 也有很多不一样的点,仅有共同点:都和异常相关;在 GCC12.2.0 上都修复了。

5. 附录——走过的弯路

上面的调查过程看起来很流畅,因为这是我优化过的,中间简化了很多非必要的步骤,实际上调查过程很曲折。我们试过很多种方案,最终才产出上面提到的最佳调查路线,下面对走过的弯路做介绍,也许在你的场景下,弯路是直路。

(1)会是框架的内存池导致的吗?Xxxx 服务采用古老的 ACE 框架(ACE · GitHub),起初怀疑是使用了 ACE 的内存分配接口导致。阅读使用接口和 ACE 内存分配相关源码后,确认未使用内存池,其内存操作接口仅是 new\delete 的二次封装而已。

(2)会是 new 和 delete 没有配套使用导致的吗?Xxxx 服务的代码较为随意,且有浓郁的 C 语言风格,基本没用 C++ 类型的构造函数来管理内存。代码中,new 出来的 byte 数组有用 free 释放的,也有用 delete 释放的,通过 demo 代码实测,new/delete/malloc/free 等内存申请和释放函数在操作 byte 数组内存时,混用不会导致内存泄漏。

(3)会是进程没有及时归还给操作系统导致的吗?去年在搜索内容架构重构项目(见文章:微服务回归单体,代码行数减少75%,性能提升1300%)中,我们遇到了回收的内存未及时归还操作系统的案例。而在本项目中,尝试使用 mallo_trim 或者 jemalloc,内存上涨速度放缓,但最终还是会内存泄漏。

(4)会是 jemalloc 没有归还导致的吗?前年我们在开发搜索中台时,曾经遇到过使用 jemalloc 的服务内存释放不及时问题。在引入 backgroud_thread,或者修改内存回收系数 page ratio,或者调整 arenas 个数,都没有效果。仔细观察也会发现 active 的页面数一直在涨,说明程序代码在申请内存之后,确实没有释放。

注1:page ratio 系数说明:我们系统自带的 jemalloc 版本为 3.6.0,采用的是较老的内存回收设计,默认 active: dirty < 8:1 时会触发内存归还操作系统。

注2:arenas 个数说明:默认会开启 4 * CPU核心数个 arenas,如果只有一个 CPU 则只会有一个 arenas。一个线程只会映射到一个 arena

(5)会是代码有 BUG 吗?

Xxxx 服务的代码 C 语言风格较浓,大部分代码没有使用 RAII 来降低内存管理负担,并且内存申请和释放较难一眼看明白:内存申请在 A 类里,内存释放在很远的 B 类上,在这上面做迭代开发,心智负担较重,稍微不注意就会出现内存泄漏。我们用 ASan 扫内存泄漏,确实发现一些极少跑到的分支没有释放内存,但这些分支 BUG 修复之后,依然存在内存泄漏。

注:本文 2024.06.26 首发在公司内网,为方便全网知识检索发布到外网,发布时部分业务相关代码和截图做了隐藏处理。

GCC8 编译优化 BUG 导致的内存泄漏的更多相关文章

  1. Android性能优化之常见的内存泄漏

    前言 对于内存泄漏,我想大家在开发中肯定都遇到过,只不过内存泄漏对我们来说并不是可见的,因为它是在堆中活动,而要想检测程序中是否有内存泄漏的产生,通常我们可以借助LeakCanary.MAT等工具来检 ...

  2. 在Activity中使用Thread导致的内存泄漏

    https://github.com/bboyfeiyu/android-tech-frontier/tree/master/issue-7/%E5%9C%A8Activity%E4%B8%AD%E4 ...

  3. 使用block的时候,导致的内存泄漏

    明确,只要在block里边用到我们自己的东西,成员变量,self之类的,我们都需要将其拿出来,把它做成弱指针以便之后进行释放. 在ZPShareViewController这个控制器中,由如下代码: ...

  4. 精华阅读第 13 期 |常见的八种导致 APP 内存泄漏的问题

    本期是移动开发精英俱乐部的第13期文章,都是以技术为主,所以这里就不过多的进行赘述了,我们直接看干货内容吧!本文系ITOM管理平台OneAPM整理. 实际项目中的MVVM(积木)模式–序章 导读:开篇 ...

  5. static关键字所导致的内存泄漏问题

    大家都知道内存泄漏和内存溢出是不一样的,内存泄漏所导致的越来越多的内存得不到回收的失手,最终就有可能导致内存溢出,下面说一下使用staitc属性所导致的内存泄漏的问题. 在dalvik虚拟机中,sta ...

  6. 一个驱动导致的内存泄漏问题的分析过程(meminfo->pmap->slabtop->alloc_calls)

    关键词:sqllite.meminfo.slabinfo.alloc_calls.nand.SUnreclaim等等. 下面记录一个由于驱动导致的内存泄漏问题分析过程. 首先介绍问题背景,在一款嵌入式 ...

  7. vue自定义指令导致的内存泄漏问题解决

    vue的自定义指令是一个比较容易引起内存泄漏的地方,原因就在于指令通常给元素绑定了事件,但是如果忘记了解绑,就会产生内存泄漏的问题. 看下面代码: directives: { scroll: { in ...

  8. 使用HandyJSON导致的内存泄漏问题相关解决方法

    在移动开发中,与服务器打交道是不可避免的,从服务器拿到的接口数据最终都会被我们解析成模型,现在比较常见的数据传输格式是json格式,对json格式的解析可以使用原生的解析方式,也可以使用第三方的,我们 ...

  9. iOS - Block产生Memory Leaks循环引用导致的内存泄漏以及解决方案

    在ARC(自动引用技术)前,Objective-c都是手动来分配释放 释放 计数内存,其过程非常复杂. ARC技术推出后,貌似世界和平了很多,但是其实ARC并不等同于Java或者C#中的垃圾回收,AR ...

  10. 动态构造LINQ表达式导致EFCore内存泄漏

    EFCore版本 v3.1.4 上述代码模拟100次的Id包含查询,并且demoExpr1和demoExpr2使用两种方式构造LINQ表达式,第二种会导致内存泄漏. 使用第一种方法构造查询条件的值,结 ...

随机推荐

  1. ansible系列(30)--ansible的role详解

    目录 1. Ansible Roles 1.1 roles目录结构 1.2 roles编写步骤 1.2.1 编写基本的roles 1.2.2 roles的调用 1.2.3 roles中使用变量 1.2 ...

  2. Seata原理浅析

    前言 Seata是阿里开源的分布式事务解决方案,本文将详细介绍 Seata 的事务模式.原理以及使用.了解之前需清楚什么是分布式事务. 一.什么是 Seata Seata 是一款开源的分布式事务解决方 ...

  3. IDEA社区版(IDEA Community Edition)创建Springboot父子项目

    1. 因为社区办不支持使用spring Spring Initializr 的方式创建项目, 但是我们可以考虑使用别的方式达到效果: 创建方式有3种: 第一种:使用https://start.spri ...

  4. WPF绑定数据源到ListBox等selector的注意事项

    如果使用CollectionViewSource绑定到控件上,会导致默认选择第一项,而使用List,SelectedItem就默认为空. 要避免默认选择第一项,就要设置 ListBox.IsSynch ...

  5. Pandas学习之路【2】

    Pandas数据查询的5种方法: 数据准备: import pandas as pd path = 'C:\\Users\\zhang\\Desktop\\ant-learn-pandas-maste ...

  6. mp4封装格式与MPEG4Extractor

    首先来看mp4的封装格式,mp4数据都被放在一个个的箱子当中,也就是box,box的字节序为网络字节序,也就是大端存储,box由header和body组成,header指明box的大小和类型,body ...

  7. 【阿里天池云-龙珠计划】薄书的机器学习笔记——K近邻(k-nearest neighbors)初探Task02

    [阿里天池云-龙珠计划]薄书的机器学习笔记--K近邻(k-nearest neighbors)初探Task02 [给各位看官请安] 大家一起来集齐七龙珠召唤神龙吧!!! 学习地址:AI训练营机器学习- ...

  8. 使用Rainbond部署Logikm,轻松管理Kafka集群

    简介 滴滴Logi-KafkaManager脱胎于滴滴内部多年的Kafka运营实践经验,是面向Kafka用户.Kafka运维人员打造的共享多租户Kafka云平台.专注于Kafka运维管控.监控告警.资 ...

  9. autojs拉人进群

    /* 微信 version:8.0.1 语言:AutoJs [https://hyb1996.github.io/AutoJs-Docs/#/] @author:奔跑的前端猿 */ auto.wait ...

  10. K8S部署ECK采集日志

    1. 部署nfs 1. 安装nfs #所有节点安装 yum install -y nfs-utils 在master节点创建nfs共享目录 mkdir -pv /data/kubernetes 编写配 ...