關(guān)于C++內(nèi)存問題排查攻略
作者 | johncchen
C++因其高性能仍然是許多關(guān)鍵應(yīng)用的首選語言,但其復(fù)雜的內(nèi)存管理也帶來了諸多挑戰(zhàn)。雖然使用現(xiàn)代C++能夠有效解決大部分問題,但掌握常用的內(nèi)存問題排查方法仍然十分必要,特別是在維護一些歷史系統(tǒng)時。本文分為上下兩篇:上篇(15)按照問題分類介紹和比較常用工具,下篇(67)通過兩個具體案例展示這些工具的組合使用,希望能為讀者帶來有益的啟發(fā)。筆者個人水平有限,文中難免存在疏漏之處,歡迎大家批評指正。
一、棧溢出(stack-overflow):查看coredump文件為主,動態(tài)檢測為輔
棧溢出的定位方法主要有靜態(tài)分析、動態(tài)檢測、查看coredump文件三種。
1. 靜態(tài)分析
(1) 原理
GCC提供了-fstack-usage選項,能輸出每個函數(shù)棧的最大使用量。開啟后,為每個編譯目標(biāo)創(chuàng)建.su文件,每行包括函數(shù)名、字節(jié)數(shù)、修飾符(static/dynamic/bounded)中的一個或多個。修飾符的含義如下:
- static: 堆棧使用量在編譯時是已知的,不依賴于任何運行時條件。
- dynamic: 堆棧使用量依賴于運行時條件,例如遞歸調(diào)用或基于輸入數(shù)據(jù)的條件分支。
- bounded: 堆棧使用量雖然依賴于運行時條件,但有一個可預(yù)知的上限。
(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是不確定的,靜態(tài)分析還有意義嗎?其實,實際代碼的.su一般是下面這種,dynamic和bounded組合在一起,雖然動態(tài)但有上限,因此可以計算出“最大”的棧用量。
xxbuild.cpp:277:5:int XXBuild::BuildPage() 528 dynamic,bounded
每個函數(shù)的棧使用量有了,如果知道函數(shù)的調(diào)用鏈就可以得出棧的最大使用量了。調(diào)用鏈可以從二進制文件中反匯編得到。
(3) 工具
靜態(tài)分析常用于資源有限的嵌入式系統(tǒng),常常集成在它們的開發(fā)工具中。但非嵌入式系統(tǒng)的這類工具比較少。開源的有 checkStackUsage等,收費的有stackanalyzer等。
注意事項:
若使用bazel編譯,默認的沙箱模式會刪除.su文件,因此編譯時需要增加--spawn_strategy=standalone選項(非沙箱模式)
2. 動態(tài)檢測
(1) 通過proc文件系統(tǒng)
pmap或查看/proc/pid/maps中的stack,缺點是進程退出后就看不到了。
(2) 捕捉操作系統(tǒng)信號
原理:
- 在 Unix-like 系統(tǒng)中,當(dāng)程序執(zhí)行非法內(nèi)存訪問時,操作系統(tǒng)會向該程序發(fā)送
SIGSEGV
信號(段錯誤)。默認情況下,接收到此信號的程序會終止。 - 如果通過注冊一個自定義的信號處理函數(shù)來攔截
SIGSEGV
信號,處理函數(shù)會收到一個siginfo_t
結(jié)構(gòu)體,其中包含錯誤的地址和寄存器狀態(tài)等上下文信息,可以判斷是否發(fā)生了棧溢出。
工具:
libsigsegv-devel,可以定義自己的處理函數(shù)來響應(yīng)內(nèi)存訪問錯誤,例如嘗試恢復(fù)、記錄錯誤信息或者優(yōu)雅地關(guān)閉程序。
注意事項:
libsigsegv是GPL協(xié)議
3. 查看coredump文件
重點關(guān)注:
- 層級是否過多,是否遞歸調(diào)用
- 棧變量是否過大
修改棧(以及線程堆棧、協(xié)程堆棧)大小后測試。
二、棧緩沖區(qū)溢出(stack-buffer-overflow):GCC -fstack-protector/C11 Annex K/AddressSanitizer
棧緩沖區(qū)溢出原因中很大一部分是數(shù)組索引/指針越界。在我看來,在項目中停止使用C風(fēng)格的指針、使用STL容器能解決大部分問題。當(dāng)然,一些項目處于維護狀態(tài),大規(guī)模改造未必合算,可以考慮使用以下工具。
1. GCC -fstack-protector
-fstack-protector的原理:
- 函數(shù)調(diào)用時,編譯器在棧上分配一個隨機生成的 canary 值(guard值),通常被放置在局部變量和控制數(shù)據(jù)(如返回地址)之間。
- 函數(shù)執(zhí)行過程中,所有的局部變量操作都應(yīng)當(dāng)保持 canary 值不變。如果有緩沖區(qū)溢出,超出局部變量的數(shù)據(jù)可能會覆蓋到 canary 值。
- 如果 canary 值被修改,程序會認為發(fā)生了棧溢出攻擊,通常會立即終止,例如通過調(diào)用 __stack_chk_fail() 函數(shù)。
有不同的保護強度-fstack-protector/-fstack-protector-all/-fstack-protector-strong/-fstack-protector-explicit,一般-fstack-protector-strong即可。
2. C11 Annex K (Bounds-checking interfaces)
使用 C11 標(biāo)準(zhǔn)中引入的strncpy_s()等函數(shù),比 strcpy()/strncpy() 等函數(shù)更安全。它要求指定源和目標(biāo)的大小,并在復(fù)制過程中檢查這些大小,以防止溢出。如果發(fā)生錯誤(如無效參數(shù)或目標(biāo)太?。?,strncpy_s() 將設(shè)置 errno 并可以選擇使程序失敗。
較低版本的gcc不支持c11, 可以使用一些第三方實現(xiàn),比如的openharmony的third_party_bounds_checking_function
3. AddressSanitizer
詳見4.1
4. Valgrind memcheck
詳見4.2
三、內(nèi)存泄漏:eBPF+火焰圖,高效直觀
1.Valgrind memcheck/AddressSanitizer/eBPF bcc-tools memleak比較
eBPF的最大的優(yōu)點是“非侵入”,不需要重新編譯或重啟業(yè)務(wù)進程,對運行速度和內(nèi)存用量的影響極小,可以忽略不計,可以線上使用。
2. eBPF bcc-tools memleak檢測原理
eBPF程序是事件驅(qū)動的,在內(nèi)核或應(yīng)用經(jīng)過特定鉤子點(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. 舉個栗子
先寫一段內(nèi)存泄漏(不斷增長)的測試代碼
#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
檢測結(jié)果如圖,符合預(yù)期~
memleak具體選項詳見-h,也可以參考官方例子。需要注意的是-O選項, attach to allocator functions in the specified object. 如果沒有使用glibc而是使用jemlloc或tcmalloc,需要使用-O指定二進制文件(靜態(tài)鏈接)或動態(tài)庫(動態(tài)鏈接)。
4. 改進memleak,支持火焰圖
實際的內(nèi)存泄漏經(jīng)常是小規(guī)模、長時間的,會混雜在大量正常的內(nèi)存申請和釋放動作中,這時候memleak文本形式的輸出就不夠直觀了。想到cpu性能調(diào)優(yōu)經(jīng)常用到的火焰圖,如果memleak能生成直觀的火焰圖就好了。
火焰圖的格式并不復(fù)雜,格式如下:
[堆棧] [采樣值]
main;foo;bar 76
PR4766有一個繪制火焰圖的簡單實現(xiàn),沒有合入主干很可惜??梢詤⒖妓?,來修改已安裝的bcc/tools/memleak。修改后執(zhí)行:
/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
在中大型項目中,火焰圖能夠很好地區(qū)分框架與業(yè)務(wù)模塊的內(nèi)存操作,便于逐級排查,非常清晰。
四、其他內(nèi)存問題: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好太多,可以考慮使用線上節(jié)點排查問題。
2. Valgrind memcheck
運行速度慢10~50倍,消耗大量內(nèi)存,可以通過關(guān)閉檢查項目來提高速度、減少內(nèi)存使用。
五、多線程/協(xié)程的數(shù)據(jù)競爭(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對運行速度和內(nèi)存消耗也有較大影響:
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,需要消耗更多內(nèi)存。我做了個測試,一個使用內(nèi)存2.5G的服務(wù),使用Valgrind helgrind或drd啟動,32G內(nèi)存都不夠、直接OOM,因此在規(guī)模大些的項目中基本不可用。
3. AddressSanitizer仍然可用
AddressSanitizer不針對data race,但能檢測內(nèi)存異常。
下篇以排查某A服務(wù)內(nèi)存問題的過程為例,演示上篇中工具的使用。其實,上篇的工具是下篇踩坑、填坑的經(jīng)驗總結(jié)。
六、低成本解決歷史代碼崩潰問題
A 服務(wù)中有一大塊老舊的業(yè)務(wù)邏輯,稱之為模塊 B,其特點如下:
- 代碼行數(shù)多, 2w+
- 大量 C 風(fēng)格字符串操作(如 strcpy 等),存在越界風(fēng)險
- 依賴大量老舊版本的第三方庫
- 需求很少,處于維護狀態(tài)
問題出現(xiàn):服務(wù)以前運行平穩(wěn),但從某天開始,線上節(jié)點隔三差五就會出現(xiàn)崩潰。查看 coredump 文件,發(fā)現(xiàn)崩潰在模塊B的代碼中, frame 0 中某些局部變量損壞。然而,重放崩潰前后一段時間內(nèi)的請求并不能復(fù)現(xiàn)崩潰,應(yīng)該是其他請求的棧緩沖區(qū)溢出,破壞了這條請求的棧。此類問題很難直接根據(jù) coredump 文件定位。
排查過程:如 2.1 中所述,使用 -fstack-protector-strong 重新編譯并上線,結(jié)果斷斷續(xù)續(xù)地因為 __stack_chk_fail 出現(xiàn)崩潰,這就好辦了。按圖索驥,發(fā)現(xiàn)是某些請求觸發(fā)了歷史 bug,導(dǎo)致一些局部變量指針越界,針對性地添加邊界判斷就修復(fù)了,從而以較小的代價解決了復(fù)雜歷史代碼的崩潰問題。
后續(xù)措施:考慮到模塊 B 可能還有其他坑,一旦出現(xiàn)問題將導(dǎo)致 A 服務(wù)的節(jié)點崩潰,影響整體 SLA。因此將模塊 B 拆分成獨立的微服務(wù) C。如果服務(wù) A 調(diào)用服務(wù) C 失敗,可以走降級鏈路,從而提高業(yè)務(wù)整體的可用性。
七、解決偶發(fā)崩潰問題
問題出現(xiàn):A 服務(wù)頻繁上線,經(jīng)常在一周內(nèi)發(fā)布三四個版本。某段時間內(nèi),崩潰的概率顯著增加。查看 coredump 文件,發(fā)現(xiàn)經(jīng)常崩潰在 STL 容器(如 std::map、std::unordered_map、std::vector 等)中 std::allocator 的析構(gòu)相關(guān)函數(shù),但backstrace不確定,有時在這個模塊中有時在那個模塊中。重放崩潰前后一段時間內(nèi)的請求無法復(fù)現(xiàn)崩潰,推測又是內(nèi)存踩踏問題。
第一次嘗試:逐一使用2.1 ~2.3的 GCC -fstack-protector /C11 Annex K/AddressSanitizer ,回放線上請求,結(jié)果都正常,這就尷尬了……
鑒于一時難以解決問題,首先采取措施確保線上穩(wěn)定:
將容器的健康檢查方式從 TCP 改為 HTTP,這樣在 core dump 開始而不是結(jié)束后就能檢測出節(jié)點異常(core 文件約 20G,core dump 過程持續(xù)幾分鐘),盡早從北極星(服務(wù)注冊與發(fā)現(xiàn)平臺)上摘除,減少對線上的影響。這樣線上可以繼續(xù)開啟coredump,方便排查問題。
第二次嘗試:
通過監(jiān)控逐漸發(fā)現(xiàn)一些規(guī)律:崩潰集中在進程啟動階段,日常運行時很少。因此懷疑與進程啟動時的狀態(tài)或特定請求有關(guān)。
下一步是復(fù)現(xiàn)問題。在崩潰概率最高的地域,新建一個旁路 workload(兩個節(jié)點),將北極星權(quán)重調(diào)為其他節(jié)點的 1/N,使用 API 定期重啟旁路 workload 的 pod。經(jīng)過幾天,問題復(fù)現(xiàn)了!
backstrace與之前類似,找不出線索。那就上工具吧,能在線上使用的檢測工具也就只有 AddressSanitizer了,編譯一版部署到旁路 workload,繼續(xù)定期重啟,等待結(jié)果……
果然,斷斷續(xù)續(xù)出現(xiàn)了一些崩潰,但查看 coredump 文件的backstrace仍難以找到有效線索。有時崩潰在插件中,有時在 encode 過程中。咨詢相關(guān)插件的同學(xué),他們也感到很奇怪,沒有思路。直到,直到,下面這個錯誤出現(xiàn):
==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()
查看代碼上下文,終于找到了原因!在某類請求中使用協(xié)程并發(fā)調(diào)用后端服務(wù),而 thread_data->string_bb(std::string 類型)變量是唯一的,多個協(xié)程同時修改 thread_data->string_bb,導(dǎo)致 double-free!由于同時寫入是小概率事件,所以崩潰是偶發(fā)的。原來是 data race 問題……
再查看提交歷史,發(fā)現(xiàn)多協(xié)程并發(fā)調(diào)用是在某個版本上線的,當(dāng)時一切正常;上百個版本之后,調(diào)用流程中增加了這行問題代碼。冗長膨脹的流程函數(shù)中新增一行代碼很難引起注意,多人開發(fā)非常容易踩坑。
徹底解決問題需要從設(shè)計入手:重構(gòu)流程,遵循單一職責(zé),將修改集中到一處,便于檢查;傳參變成只讀引用,消除 data race。
測試通過,上線,不再崩潰!
總結(jié)
大部分問題,尤其是難以排查的問題,應(yīng)該在設(shè)計階段就被解決掉,越往后代價越大。正所謂“善戰(zhàn)者無赫赫之功”。