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 首发在公司内网,为方便全网知识检索发布到外网,发布时部分业务相关代码和截图做了隐藏处理。

posted on 2024-07-06 21:14  烛秋  阅读(1420)  评论(13编辑  收藏  举报