GCC8 編譯最佳化 BUG 導致的記憶體洩漏

烛秋發表於2024-07-06

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 首發在公司內網,為方便全網知識檢索釋出到外網,釋出時部分業務相關程式碼和截圖做了隱藏處理。

相關文章