作為 C/C++ 開(kāi)發(fā)人員,內(nèi)存泄漏是最容易遇到的問(wèn)題之一,這是由 C/C++ 語(yǔ)言的特性引起的。眾所周知,開(kāi)源的時(shí)序數(shù)據(jù)庫(kù)(Time Series Database)TDengine OSS 就是使用 C 語(yǔ)言進(jìn)行底層自研的,也因此,針對(duì)內(nèi)存泄漏問(wèn)題,我們的研發(fā)小伙伴也做了諸多研究和思考。在本篇文章中,我們將從 GitHub 上的一個(gè)關(guān)于內(nèi)存泄漏的 issue 入手,和大家探討下導(dǎo)致內(nèi)存泄漏的原因,以及如何避免和定位內(nèi)存泄漏。
issue 鏈接:https://github.com/taosdata/TDengine/issues/18276
從上述 issue 的詳細(xì)描述可以看到,這是一個(gè)疑似內(nèi)存泄漏問(wèn)題,該用戶使用 TDengine OSS 從 3.0.1.6 版本開(kāi)始一直升級(jí)測(cè)到 3.0.2.2 版本,內(nèi)存泄漏問(wèn)題一直存在。該問(wèn)題簡(jiǎn)化總結(jié)即:在只有一個(gè)簡(jiǎn)單查詢(例如 select count(*) from 子表)且不斷重復(fù)查詢的情況下,taosd 內(nèi)存持續(xù)上漲。測(cè)試中 taosd 內(nèi)存占用從 400MB 可以一直漲到 24GB+。期間,另有其他用戶也評(píng)論反饋遇到相同的問(wèn)題,在內(nèi)存小的情況下,最終 taosd 會(huì) OOM。
問(wèn)題定位
遇到這種疑似內(nèi)存泄漏問(wèn)題時(shí),第一步應(yīng)該先用工具跑,在使用常用工具 Valgrind、Address sanitizer 嘗試之后,結(jié)果都報(bào)告沒(méi)有內(nèi)存泄漏。這種情況在之前 2.x 版本也曾發(fā)生過(guò),當(dāng)時(shí)研發(fā)人員懷疑 glibc 的內(nèi)存管理器有問(wèn)題(不完善),然后切換到 jemalloc 或 tcmalloc,但是不是真的是 glibc 有 BUG 或者內(nèi)存空洞問(wèn)題導(dǎo)致的?我們需要尋找證據(jù)。
問(wèn)題分析
在開(kāi)始動(dòng)手之前我們先要搞清楚概念,到底什么是內(nèi)存泄漏?我們都了解內(nèi)存泄漏的最大害處是導(dǎo)致程序最終 OOM,在此之前能觀察到的現(xiàn)象是進(jìn)程內(nèi)存使用量持續(xù)上漲。那是不是只要進(jìn)程 OOM 了或者內(nèi)存持續(xù)上漲就是有內(nèi)存泄漏?并不是。簡(jiǎn)單來(lái)說(shuō),內(nèi)存泄漏是指不再使用的內(nèi)存沒(méi)有釋放,這必然導(dǎo)致內(nèi)存持續(xù)上漲直至 OOM,但不是只有內(nèi)存泄漏會(huì)導(dǎo)致內(nèi)存持續(xù)上漲和 OOM,上面提到的內(nèi)存空洞問(wèn)題或者緩存也會(huì)導(dǎo)致同樣的后果。所以嚴(yán)格來(lái)說(shuō),上述 issue 遇到的是內(nèi)存持續(xù)上漲或 OOM 問(wèn)題,并不一定是內(nèi)存泄漏。但是不管是哪一種情況造成的,后果都是嚴(yán)重的,研發(fā)人員都要找到問(wèn)題并解決它。
常見(jiàn)的可能造成內(nèi)存持續(xù)上漲的問(wèn)題有內(nèi)存泄漏、內(nèi)存空洞、緩存三類,而我們常用的 Valgrind、Address sanitizer 能夠發(fā)現(xiàn)解決的都是內(nèi)存泄漏問(wèn)題,而對(duì)于內(nèi)存空洞和緩存問(wèn)題卻無(wú)法檢測(cè),這就是為什么很多時(shí)候會(huì)有內(nèi)存在漲但是工具檢測(cè)不到問(wèn)題的情況發(fā)生。但想要說(shuō)服用戶這是空洞問(wèn)題也并不那么容易,單純的內(nèi)存空洞問(wèn)題通常只會(huì)導(dǎo)致內(nèi)存占用多的問(wèn)題,空洞部分是可以重復(fù)利用的,也就是說(shuō)通常不會(huì)造成內(nèi)存持續(xù)增長(zhǎng)問(wèn)題,只在一些極端使用場(chǎng)景下可能會(huì)出現(xiàn)持續(xù)增長(zhǎng)的問(wèn)題。如果工具可靠且可以排除內(nèi)存空洞問(wèn)題,那大概率就是緩存問(wèn)題了,而 taosd 在單個(gè)查詢重復(fù)執(zhí)行的場(chǎng)景下又沒(méi)有明顯的緩存問(wèn)題。理論分析又陷入困境,我們需要一種能發(fā)現(xiàn)解決這三類問(wèn)題的方法和工具。
雖然是三類問(wèn)題,但他們也有共同點(diǎn),那就是都是因?yàn)閮?nèi)存的分配和釋放造成的,如果能夠找到并記錄每個(gè)內(nèi)存分配和釋放的點(diǎn)就可以分析屬于什么狀況了:
- 分配后釋放了 – 沒(méi)有問(wèn)題
- 分配后未釋放 – 需要根據(jù)代碼分析是內(nèi)存泄漏還是緩存
既然有了思路,接下來(lái)就是思考如何實(shí)現(xiàn)了,核心問(wèn)題是怎么找到并記錄每個(gè)內(nèi)存分配和釋放的點(diǎn)?開(kāi)發(fā)代碼可以記錄每一個(gè) taosd 自己的內(nèi)存分配和釋放,但是開(kāi)發(fā)工作量不小短時(shí)間內(nèi)難以完成,更重要的原因在于 taosd 的進(jìn)程空間中除了我們自己開(kāi)發(fā)的代碼外還有第三方庫(kù)包括 glibc 的代碼,雖然出問(wèn)題的概率較小,但如果是我們的使用方式有問(wèn)題也是存在出問(wèn)題的可能的,這些代碼中出現(xiàn)的問(wèn)題怎么辦?我的答案是向下找接口,即在系統(tǒng)調(diào)用層面捕捉內(nèi)存的分配和釋放。
背景知識(shí)
- glibc 中的內(nèi)存管理器 ptmalloc 通過(guò) brk、mmap、munmap 3 個(gè)系統(tǒng)調(diào)用從 OS 分配和釋放內(nèi)存,對(duì)于大塊內(nèi)存每次都通過(guò) mmap、munmap 直接分配和回收,對(duì)于小塊內(nèi)存則是通過(guò) brk 從堆上分配一個(gè)大片內(nèi)存然后進(jìn)行內(nèi)部切分來(lái)分配、釋放、復(fù)用,因此默認(rèn)情況下單個(gè)小塊內(nèi)存的分配是不一定能從系統(tǒng)調(diào)用的追蹤中看到的。這里的“大塊”與“小塊”的邊界值大小默認(rèn)是 128K,同時(shí)提供了 mallopt(M_MMAP_THRESHOLD,threshold_value)來(lái)改變這個(gè)邊界值。這就給我們提供了一種便利,只要將這個(gè)值調(diào)到足夠小就可以觀察到用戶空間所有的內(nèi)存分配與釋放。
- strace 命令可以捕獲所有用戶空間程序發(fā)出的系統(tǒng)調(diào)用和其參數(shù)信息,帶來(lái)的便利就是可以觀察到所有內(nèi)存分配與釋放的系統(tǒng)調(diào)用,同時(shí)對(duì)于日志信息可以被記錄觀察到。
定位步驟
- taosd 啟動(dòng)時(shí)調(diào)用如下代碼強(qiáng)制所有內(nèi)存分配與釋放都通過(guò) mmap、munmap 進(jìn)行,進(jìn)而可以觀察到用戶所有內(nèi)存的分配與釋放。
int ret = mallopt(M_MMAP_THRESHOLD, 0);
if (0 == ret) {
return TAOS_SYSTEM_ERROR(errno);
}
- 配置中打開(kāi) taosd 所有模塊的 DEBUG 日志開(kāi)關(guān),關(guān)閉異步日志,啟動(dòng) taosd 進(jìn)程,啟動(dòng)測(cè)試程序。
- shell 中運(yùn)行下面的命令捕捉系統(tǒng)調(diào)用。
strace -TttFf -e write=0,1,2,3 -p `pidof taosd` -o strace_log.txt
- 在測(cè)試執(zhí)行完成后或觀察到明顯的內(nèi)存增長(zhǎng)后停止 strace 命令,strace_log.txt 內(nèi)容示例如下:
1230673 12:56:10.273506 <... futex resumed>) = 0 <0.001681>
1230741 12:56:10.273535 write(3, "01/13 12:56:10.273516 01230741 Q"..., 129 <unfinished ...>
1230673 12:56:10.273547 futex(0x7ff766f4d01c, FUTEX_WAIT_BITSET_PRIVATE|FUTEX_CLOCK_REALTIME, 3, NULL, FUTEX_BITSET_MATCH_ANY <unfinished ...>
1230741 12:56:10.273566 <... write resumed>) = 129 <0.000022>
| 00000 30 31 2f 31 33 20 31 32 3a 35 36 3a 31 30 2e 32 01/13 12:56:10.2 |
| 00010 37 33 35 31 36 20 30 31 32 33 30 37 34 31 20 51 73516 01230741 Q |
| 00020 52 59 20 51 49 44 3a 30 78 65 33 39 37 66 65 37 RY QID:0xe397fe7 |
| 00030 63 33 65 30 38 38 36 63 30 2c 54 49 44 3a 30 78 c3e0886c0,TID:0x |
| 00040 63 33 32 34 2c 45 49 44 3a 30 20 74 61 73 6b 20 c324,EID:0 task |
| 00050 73 74 61 74 75 73 20 75 70 64 61 74 65 64 20 66 status updated f |
| 00060 72 6f 6d 20 45 58 45 43 55 54 49 4e 47 20 74 6f rom EXECUTING to |
| 00070 20 50 41 52 54 49 41 4c 5f 53 55 43 43 45 45 44 PARTIAL_SUCCEED |
| 00080 0a . |
1230741 12:56:10.273603 futex(0x7ff766f4d01c, FUTEX_WAKE_PRIVATE, 1) = 1 <0.000027>
1230749 12:56:10.273644 <... futex resumed>) = 0 <0.001744>
1230741 12:56:10.273655 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0 <unfinished ...>
1230749 12:56:10.273669 write(3, "01/13 12:56:10.271877 01230749 U"..., 83 <unfinished ...>
1230741 12:56:10.273681 <... mmap resumed>) = 0x7ff50f4c8000 <0.000020>
- 通過(guò)下面的 shell 命令從 strace 生成的文件中提取所有的內(nèi)存分配地址與釋放地址,map.txt 文件中的每行內(nèi)容為一個(gè)內(nèi)存分配的地址,unmap.txt 文件中的每行內(nèi)容為一個(gè)內(nèi)存釋放的地址。
egrep "mmap|mremap" strace_log.txt |grep -v unfinished|awk -F "=" '{print $2}'|awk '{print $1}'>map.txt
egrep "munmap|mremap" strace_log.txt |grep -v resumed| awk -F "(" '{print $2}'|awk -F "," '{print $1}'>unmap.txt
- 通過(guò)自己開(kāi)發(fā)的一個(gè)小工具從 map.txt 依次讀取每一行,然后在 unmap.txt 文件中依次尋找該地址是否存在,如果存在則該內(nèi)存分配釋放沒(méi)有問(wèn)題;如果不存在,則該地址(A)為內(nèi)存泄漏或者一個(gè)緩存的地址。
- 在 strace_log.txt 中找到最后一次 mmap 分配的上一步找到的可疑地址 (A),通過(guò)線程號(hào)觀察該次內(nèi)存分配的上下文信息(系統(tǒng)調(diào)用和日志信息),進(jìn)而在代碼中找到對(duì)應(yīng)的內(nèi)存分配的地方。
- 通過(guò)代碼分析確認(rèn)該次分配的內(nèi)存在 strace 觀察的時(shí)間段內(nèi)未釋放是否是正常的程序行為,如果是則可以劃分為緩存類別;如果不是則判斷為內(nèi)存泄漏或異常緩存,修改后驗(yàn)證直至內(nèi)存不再增長(zhǎng)。
說(shuō)明
- 打開(kāi) taosd 所有模塊日志、關(guān)閉異步日志、跟蹤所有系統(tǒng)調(diào)用的目的都是為了在第 7 步有足夠的上下文信息判斷內(nèi)存分配的代碼,但對(duì)于日志較少的模塊我們可能需要通過(guò)增加日志逐步縮小范圍來(lái)最終找到內(nèi)存的分配點(diǎn);
- 在第 4 步我們需要充足時(shí)間保證測(cè)試完整執(zhí)行完,進(jìn)而保證最終找到可疑地址(A)不是因?yàn)橛^察時(shí)間不足還未等到 munmap 的場(chǎng)景(排除干擾);
- 使用限制:只適用于 glibc 的內(nèi)存管理器(Linux + glibc);
- 工具代碼如下,編譯后跟第 5 步生成的結(jié)果放在一個(gè)目錄直接運(yùn)行即可(無(wú)需參數(shù)):
#include "stdlib.h"
#include "stdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
char in1[16] = {0};
char in2[500*1048576][16] = {0};
main()
{
FILE* fd1=fopen("map.txt", "r");
FILE* fd2=fopen("unmap.txt", "r");
int i = 0, n = 0, found = 0,m=0, minIdx = 0, non0 = 0;
while(fgets(in2[i], sizeof(in2[0]), fd2) != NULL)
{
if (in2[i][14] = '\n') {
in2[i][14] = 0;
}
i++;
}
printf("%d rcords in unmap.txt read\n", i);
while(fgets(in1, sizeof(in1), fd1) != NULL)
{
if (in1[14] = '\n') {
in1[14] = 0;
}
m++;
non0 = 0;
for(n=minIdx;n<i;n++)
{
if(in2[n][0]==0) {
if (0 == non0) {
minIdx++;
}
continue;
}
non0 = 1;
if((in1[0]==in2[n][0]) && (0==strcmp(in1, in2[n])))
{
in2[n][0]=0;
break;
}
}
if (n==i)
{
found++;
printf("%dth found, %s, it's the %dth in map.txt\n", found, in1, m);
//if(found>=100)
// break;
}
if (m > (minIdx+10000)) {
minIdx++;
}
}
}
定位結(jié)果
通過(guò)使用上面介紹的方法,我們最終定位到了兩個(gè)問(wèn)題:
- 一處內(nèi)存錯(cuò)誤問(wèn)題,按照上面的分類屬于非預(yù)期的緩存造成的:
atexit(cleanupRefPool);
說(shuō)明:我們?cè)趧?chuàng)建每個(gè)查詢子任務(wù)時(shí)都直接調(diào)用了上面這個(gè)語(yǔ)句,它會(huì)每次緩存一個(gè)函數(shù)地址,最終在進(jìn)程退出時(shí)又都全部釋放了,因此不屬于內(nèi)存泄漏,Valgrind 和 Address sanitizer 都檢測(cè)不到,這是造成查詢內(nèi)存一直增長(zhǎng)的原因。
- 一處可優(yōu)化的緩存管理,不是內(nèi)存增長(zhǎng)的原因,但是針對(duì)特定使用場(chǎng)景緩存有優(yōu)化空間。
總結(jié)與后續(xù)
上述問(wèn)題是一個(gè)從 3.0.0.0 版本開(kāi)始就一直存在的“內(nèi)存泄漏”問(wèn)題,任何一個(gè)查詢都存在,直到 3.0.2.5 版本出來(lái)之后,我們才可以說(shuō) taosd 終于沒(méi)有“內(nèi)存泄漏”問(wèn)題了。本文通過(guò)一種不需要額外代碼開(kāi)發(fā)的方法,在傳統(tǒng)的內(nèi)存泄漏檢測(cè)工具能力范圍之外,一站式定位解決進(jìn)程內(nèi)存占用持續(xù)增長(zhǎng)或 OOM 問(wèn)題,讓徹底解決這類問(wèn)題成為可能。此外面對(duì)這一類問(wèn)題,目前 TDengine OSS 已經(jīng)在 taosd/taosc 增加在線開(kāi)閉內(nèi)存調(diào)試模式,可以隨時(shí)在現(xiàn)場(chǎng)定位內(nèi)存增長(zhǎng)問(wèn)題,不需要安裝工具,不需要編譯 ASAN 版本,尤其適合解決 Valgrind/ASAN 發(fā)現(xiàn)不了的內(nèi)存增長(zhǎng)問(wèn)題。



互聯(lián)網(wǎng).png)



-1.png)




.png)


證.png)


伙伴.png)
伙伴.png)
伙伴.png)



