排查一个潜在的内存访问问题 -- 用 C 写代码的日常

最近几个月,我开始涉足 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==
{
   
   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 过的内存导致的。

{
   
   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++ 作为开发语言,就要有配套的安全措施。

你可能感兴趣的:(c)