GCC8 编译优化 BUG 导致的内存泄漏

gcc8,bug · 浏览次数 : 18

小编点评

本文主要讨论了一个公司在重构改造过程中遇到的内存泄漏问题及其定位过程。 **主要内容概述如下**: 1. **背景**: 第一章介绍了公司接手的一套老系统的现状,包括其迭代效率和稳定性较差的问题,以及计划进行的重构改造。同时,提到了为了提升效率,公司将升级编译工具和GCC版本。 2. **内存泄漏现象**: 第二章详细描述了内存泄漏的现象,包括泄漏的服务名称、泄漏时的内存增长情况、以及初步定位到泄漏版本的过程。通过调查和尝试,发现是升级 bazel 和 GCC8 引入的问题,并通过降级 GCC4 解决了线上问题。 3. **内存泄漏原因和避开方法**: 第三章分析了内存泄漏的原因,并提供了避开方法。通过使用 jemalloc 工具定位问题,模拟现场确认问题的普遍性,并使用 GDB 调试确认问题所在。最后,提出了通过升级 GCC 版本来解决问题的方案。 4. **内存泄漏定位的经验分享**: 第四章分享了在定位内存泄漏过程中的经验和教训,包括使用 jemalloc 工具、模拟现场确认问题的普遍性、使用 GDB 调试以及参考 GCC 社区的反馈。 总的来说,本文详细记录了一个公司在重构改造过程中遇到的内存泄漏问题及其定位过程,为类似问题的解决提供了宝贵的经验和教训。

正文

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)Xxxx 使用自己编译的 jemalloc,并开启定期 dump 堆分配信息。

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

./Xxxx 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 对比图略)

结合 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 导致的内存泄漏相似的内容: