排查一個潛在的記憶體訪問問題 — 用 C 寫程式碼的日常

spacewander發表於2019-05-14

最近幾個月,我開始涉足 C 開發的領域,遇到的最大的挑戰在於如何管理好記憶體。
從異常情況下避免記憶體洩漏,到排查程式碼邏輯裡面的 invalid read,還有複用過程中沒能清理好資料的問題,幾乎各種坑都體驗過一次。

舉半個月前經歷的一件事為例。
跑單元測試的過程中,我發現 valgrind 報了個 invalid read 錯誤:

==3297== Invalid read of size 2
==3297==    at 0x5E2E6BD: getenv (getenv.c:84)
==3297==    by 0x844583D: ??? (in /usr/lib/x86_64-linux-gnu/libgnutls.so.30.13.1)
==3297==    by 0x40111A9: _dl_fini (dl-fini.c:235)
==3297==    by 0x5E2EEBF: __run_exit_handlers (exit.c:83)
==3297==    by 0x5E2EF19: exit (exit.c:105)
==3297==    by 0x1E0D26: ngx_master_process_exit
==3297==    by 0x1E33D4: ngx_single_process_cycle
==3297==    by 0x1B6BD5: main
==3297==  Address 0xafaaca0 is 0 bytes inside a block of size 635 free`d
==3297==    at 0x4C30D3B: free (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==3297==    by 0x1B93BE: ngx_destroy_pool
==3297==    by 0x1E0D1F: ngx_master_process_exit
==3297==    by 0x1E33D4: ngx_single_process_cycle
==3297==    by 0x1B6BD5: main
==3297==  Block was alloc`d at
==3297==    at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==3297==    by 0x1DCC9E: ngx_alloc
==3297==    by 0x1B9254: ngx_malloc.isra.0
==3297==    by 0x1CC6D7: ngx_conf_read_token
==3297==    by 0x1CC6D7: ngx_conf_parse
==3297==    by 0x1CA11C: ngx_init_cycle
==3297==    by 0x1B69E5: main
==3297==
{
   <insert_a_suppression_name_here>
   Memcheck:Addr2
   fun:getenv
   obj:/usr/lib/x86_64-linux-gnu/libgnutls.so.30.13.1
   fun:_dl_fini
   fun:__run_exit_handlers
   fun:exit
   fun:ngx_master_process_exit
   fun:ngx_single_process_cycle
   fun:main
}

這個是在新寫入的測試用例上發現的,所以很快就搞出個最小可重現的例子:

env k=v;

events {
    worker_connections  64;
}

看程式碼,Nginx 的 env k=v 這部分記憶體是在解析配置時在全域性記憶體池分配的。按 man page 的說法,env 使用的記憶體必須是靜態分配的。
當 Nginx 釋放了全域性記憶體池之後,如果再訪問這部分記憶體,顯然會報 invalid read 的錯誤。

這個問題很明顯,那麼問題來了:為什麼沒有人報告過這個問題呢?是否意味著這個是“明知故犯”,只能 suppress 掉的?

隨後我便跟同事討論起了這一問題。鑑於我們修改過 Nginx 的原始碼,同事建議再在原生 Nginx 上嘗試重現下。雖然我知道我們對 Nginx 的修改沒動過這一塊,但還是試了一下。有趣的是,在原生 Nginx 上沒法重現這一顯而易見的問題。

一開始我認為是我們對 Nginx 的修改搞出了記憶體問題了。往前嘗試了幾個版本,都能重現同樣的問題。看來靠查詢修改歷史是很難定位到問題所在了。但是,看程式碼,這裡應該是會有問題的,為什麼原生的 Nginx 上無法重現呢?

在一籌莫展的時候,我注意到一個之前沒有看到的盲點。這個訪問異常,是在 exit 之後訪問 free 過的記憶體導致的。

{
   <insert_a_suppression_name_here>
   Memcheck:Addr2
   fun:getenv
   obj:/usr/lib/x86_64-linux-gnu/libgnutls.so.30.13.1
   fun:_dl_fini
   fun:__run_exit_handlers
   fun:exit <- 看這裡!
   fun:ngx_master_process_exit
   fun:ngx_single_process_cycle
   fun:main
}

Nginx 並沒有給程式註冊 exit handler,所以如果只用原生的 Nginx,就不會有 exit 之後的這段邏輯,當然就不會報告說訪問異常了。

觸發報告的 exit handler 是在 libgnutls 的程式碼裡的,所以問題現在變成 libgnutls 是誰引入的?我不認識 libgnutls 這個庫,所以它不是我們直接引入的依賴。那麼先找出直接依賴的庫,然後一個個審訊吧:

$ readelf -d $(which nginx)

然後對於每個庫,現在可以用 ldd 去列出它們引入的依賴了。
最終發現是 libpq 引入了這個庫:

$ ldd /usr/lib/x86_64-linux-gnu/libpq.so.5
...
libgnutls.so.30 => /usr/lib/x86_64-linux-gnu/libgnutls.so.30

(嗯,libpq 引入的依賴可是相當多,當時的輸出結果可是嚇了我一跳)

所以我終於搞明白了,只有在編譯 Nginx 時用到了 libpq,跑 valgrind 才會有這個報告。

舉這個親身經歷是為了說明兩件事:

第一,雖然 valgrind 的用法是傻瓜式的,但是排查 valgrind 報告出來的問題可不是傻瓜式的。上面的例子,只是幾個月來我面對過的記憶體問題中,比較有趣的一個。有些記憶體問題,需要你絞盡腦汁、用上渾身解數去嘗試解決。如果不是非常有必要,請勿採用 C/C++ 來編寫程式。Java/Go 是潛在的選擇,不過對於追求效能的程式,它們並不能代替 C/C++,至少目前不能。Rust 也是可能的選擇,據說能從編譯器的級別做到 memory safe,而且效能跟 C/C++ 是同一級別的。既能避免記憶體問題,又不至於喪失效能上的優勢,對於正苦於解決記憶體問題的 C/C++ 程式設計師來說,猶如福音。我打算今年抽出時間學一下 Rust,看看這一門語言是不是真正的解決方案。

第二,如果你是在遇到記憶體問題(比如莫名其妙的 core dump、涓涓細流的記憶體洩漏)才想起 valgrind、asan 之類的工具,很不幸,臨時抱佛腳通常不會得到佛祖的保佑。為了儘早消滅潛在的記憶體問題,我們會用 valgrind 執行全部測試用例。在這道防線之外,目前還正在著手把 asan 和 fuzzy 測試結合起來,儘可能地發現記憶體問題。如果沒有持之以恆的施工安全保障,一旦出 core dump 再去排查,其難度可想而知。所謂沒有金剛鑽,不攬瓷器活,就是這個道理。如果選擇了用 C/C++ 作為開發語言,就要有配套的安全措施。

相關文章