關于C++內存問題排查攻略
作者 | johncchen
C++因其高性能仍然是許多關鍵應用的首選語言,但其復雜的內存管理也帶來了諸多挑戰。雖然使用現代C++能夠有效解決大部分問題,但掌握常用的內存問題排查方法仍然十分必要,特別是在維護一些歷史系統時。本文分為上下兩篇:上篇(15)按照問題分類介紹和比較常用工具,下篇(67)通過兩個具體案例展示這些工具的組合使用,希望能為讀者帶來有益的啟發。筆者個人水平有限,文中難免存在疏漏之處,歡迎大家批評指正。
一、棧溢出(stack-overflow):查看coredump文件為主,動態檢測為輔
棧溢出的定位方法主要有靜態分析、動態檢測、查看coredump文件三種。
1. 靜態分析
(1) 原理
GCC提供了-fstack-usage選項,能輸出每個函數棧的最大使用量。開啟后,為每個編譯目標創建.su文件,每行包括函數名、字節數、修飾符(static/dynamic/bounded)中的一個或多個。修飾符的含義如下:
- 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) 捕捉操作系統信號
原理:
- 在 Unix-like 系統中,當程序執行非法內存訪問時,操作系統會向該程序發送
SIGSEGV
信號(段錯誤)。默認情況下,接收到此信號的程序會終止。 - 如果通過注冊一個自定義的信號處理函數來攔截
SIGSEGV
信號,處理函數會收到一個siginfo_t
結構體,其中包含錯誤的地址和寄存器狀態等上下文信息,可以判斷是否發生了棧溢出。
工具:
libsigsegv-devel,可以定義自己的處理函數來響應內存訪問錯誤,例如嘗試恢復、記錄錯誤信息或者優雅地關閉程序。
注意事項:
libsigsegv是GPL協議
3. 查看coredump文件
重點關注:
- 層級是否過多,是否遞歸調用
- 棧變量是否過大
修改棧(以及線程堆棧、協程堆棧)大小后測試。
二、棧緩沖區溢出(stack-buffer-overflow):GCC -fstack-protector/C11 Annex K/AddressSanitizer
棧緩沖區溢出原因中很大一部分是數組索引/指針越界。在我看來,在項目中停止使用C風格的指針、使用STL容器能解決大部分問題。當然,一些項目處于維護狀態,大規模改造未必合算,可以考慮使用以下工具。
1. GCC -fstack-protector
-fstack-protector的原理:
- 函數調用時,編譯器在棧上分配一個隨機生成的 canary 值(guard值),通常被放置在局部變量和控制數據(如返回地址)之間。
- 函數執行過程中,所有的局部變量操作都應當保持 canary 值不變。如果有緩沖區溢出,超出局部變量的數據可能會覆蓋到 canary 值。
- 如果 canary 值被修改,程序會認為發生了棧溢出攻擊,通常會立即終止,例如通過調用 __stack_chk_fail() 函數。
有不同的保護強度-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() 等函數更安全。它要求指定源和目標的大小,并在復制過程中檢查這些大小,以防止溢出。如果發生錯誤(如無效參數或目標太?。瑂trncpy_s() 將設置 errno 并可以選擇使程序失敗。
較低版本的gcc不支持c11, 可以使用一些第三方實現,比如的openharmony的third_party_bounds_checking_function
3. AddressSanitizer
詳見4.1
4. Valgrind memcheck
詳見4.2
三、內存泄漏:eBPF+火焰圖,高效直觀
1.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 <iostream>
#include <chrono>
#include <thread>
#include <vector>
#include <string>
void LeakOnce(std::vector<std::string>& 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<std::string> 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,其特點如下:
- 代碼行數多, 2w+
- 大量 C 風格字符串操作(如 strcpy 等),存在越界風險
- 依賴大量老舊版本的第三方庫
- 需求很少,處于維護狀態
問題出現:服務以前運行平穩,但從某天開始,線上節點隔三差五就會出現崩潰。查看 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<char>::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<char>::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<std::allocator<char> >::deallocate(std::allocator<char>&, 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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::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<char, std::char_traits<char>, std::allocator<char> >::append(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > 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<char, std::char_traits<char>, std::allocator<char> >::operator+=(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > 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<char>::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<char>::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<std::allocator<char> >::deallocate(std::allocator<char>&, 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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::_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<char, std::char_traits<char>, std::allocator<char> >::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<char, std::char_traits<char>, std::allocator<char> >::append(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > 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<char, std::char_traits<char>, std::allocator<char> >::operator+=(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > 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。
測試通過,上線,不再崩潰!
總結
大部分問題,尤其是難以排查的問題,應該在設計階段就被解決掉,越往后代價越大。正所謂“善戰者無赫赫之功”。