写点什么

linux 下定位多线程内存越界问题实践总结

用户头像
小Q
关注
发布于: 2020 年 11 月 13 日

最近定位了在一个多线程服务器程序(OceanBase MergeServer)中,一个线程非法篡改另一个线程的内存而导致程序 core 掉的问题。定位这个问题历经曲折,尝试了各种内存调试的办法。往往感觉就要柳暗花明了,却发现又进入了另一个死胡同。最后,使用强大的 mprotect+backtrace+libsigsegv 等工具成功定位了问题。整个定位过程遇到的问题和解决办法对于多线程内存越界问题都很典型,简单总结一下和大家分享。


现象


core 是在系统集成测试过程中发现的。服务器程序 MergeServer 有一个 50 个工作线程组成的线程池,当使用 8 个线程的测试程序通过 MergeServer 读取数据时,后者偶尔会 core 掉。用 gdb 查看 core 文件,发现 core 的原因是一个指针的地址非法,当进程访问指针指向的地址时引起了段错误(segment fault)。见下图。



发生越界的指针 ptr 位于一个叫做 cname 的对象中,而这个对象是一个动态数组 field_columns_的第 10 个元素的成员。如下图。



复现问题


之后,花了 2 天的时间,终于找到了重现问题的方法。重现多次,可以观察到如下一些现象:


\1. 随着客户端并发数的加大(从 8 个线程到 16 个线程),出 core 的概率加大;


\2. 减少服务器端线程池中的线程数(从 50 个到 2 个),就不能复现 core 了。


\3. 被篡改的那个指针,总是有一半(高 4 字节)被改为了 0,而另一半看起来似乎是正确的。


\4. 请看前一节,重现多次,每次出 core,都是因为 field_columns 这个动态数组的第 10 个元素 data[9]的 cname 成员的 ptr 成员被篡改。这是一个不好解释的奇怪现象。


\5. 在代码中插入检查点,从 field_columns 中内容最初产生到读取导致越界的这段代码序列中“埋点”,既使用二分查找法定位篡改 cname 的代码位置。结果发现,程序有时 core 到检查点前,有时又 core 到检查点后。


综合以上现象,初步判断这是一个多线程程序中内存越界的问题。


使用 glibc 的 MALLOC_CHECK_


因为是一个内存问题,考虑使用一些内存调试工具来定位问题。因为 OB 内部对于内存块有自己的缓存,需要去除它的影响。修改 OB 内存分配器,让它每次都直接调用 c 库的 malloc 和 free 等,不做缓存。然后,可以使用 glibc 内置的内存块完整性检查功能。


使用这一特性,程序无需重新编译,只需要在运行的时候设置环境变量 MALLOC_CHECK_(注意结尾的下划线)。每当在程序运行过程 free 内存给 glibc 时,glibc 会检查其隐藏的元数据的完整性,如果发现错误就会立即 abort。用类似下面的命令行启动 server 程序:


export MALLOC_CHECK_=2
复制代码


bin/mergeserver -z 45447 -r 10.232.36.183:45401 -p45441
复制代码

使用 MALLOC_CHECK_以后,程序 core 到了不同的位置,是在调用 free 时,glibc 检查内存块前面的校验头错误而 abort 掉了。如下图。



但这个 core 能带给我们想信息也很少。我们只是找到了另外一种稍高效地重现问题的方法而已。或许最初看到的 core 的现象是延后显现而已,其实“更早”的时刻内存就被破坏掉了。


valgrind


glibc 提供的 MALLOC_CHECK_功能太简单了,有没有更高级点的工具不光能够报告错误,还能分析出问题原因来?我们自然想到了大名鼎鼎的 valgrind。用 valgrind 来检查内存问题,程序也不需要重新编译,只需要使用 valgrind 来启动:


nohup valgrind --error-limit=no --suppressions=suppress bin/mergeserver -z 45447 -r 10.232.36.183:45401 -p45441 >nohup.out &
复制代码

默认情况下,当 valgrind 发现了 1000 中不同的错误,或者总数超过 1000 万次错误后,会停止报告错误。加了--error-limit=no 以后可以禁止这一特性。--suppressions 用来屏蔽掉一些不关心的误报的问题。经过一翻折腾,用 valgrind 复现不了 core 的问题。valgrind 报出的错误也都是一些与问题无关的误报。大概是因为 valgrind 运行程序大约会使程序性能慢 10 倍以上,这会影响多线程程序运行时的时序,导致 core 不能复现。此路不通。


需要 C/C++ Linux 高级服务器架构师学习资料后台私信“资料”(包括 C/C++,Linux,golang 技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg 等)



magic number


既然 MALLOC_CHECK_可以检测到程序的内存问题,我们其实想知道的是谁(哪段代码)越了界。此时,我们想到了使用 magic number 填充来标示数据结构的方法。如果我们在被越界的内存中看到了某个 magic number,就知道是哪段代码的问题了。


首先,修改对于 malloc 的封装函数,把返回给用户的内存块填充为特殊的值(这里为 0xEF),并且在开始和结束部分各多申请 24 字节,也填充为特殊值(起始 0xBA,结尾 0xDC)。另外,我们把预留内存块头部的第二个 8 字节用来存储当前线程的 ID,这样一旦观察到被越界,我们可以据此判定是哪个线程越的界。代码示例如下。



然后,在用户程序通过我们的 free 入口释放内存时,对我们填充到边界的 magic number 进行检查。同时调用 mprobe 强制 glibc 对内存块进行完整性检查。



最后,给程序中所有被怀疑的关键数据结构加上 magic number,以便在调试器中检查内存时能识别出来。例如



好了,都加好了。用 MALLOC_CHECK_的方式重新运行。程序如我们所愿又 core 掉了,检查被越界位置的内存:



如上图,红色部分是我们自己填充的越界检查头部,可以看到它没有被破坏。其中第二行存储的线程号经过确认确实等于我们当前线程的线程号。


蓝色部分为前一个动态内存分配的结尾,也是完整的(24 个字节 0xdc)。0x44afb60 和 0x44afb68 两行所示的内存为 glibc malloc 存储自身元数据的地方,程序 core 掉的原因是它检查这两行内容的完整性时发现了错误。由此推断,被非法篡改的内容小于 16 个字节。仔细观察这 16 字节的内容,我们没有看到熟悉的 magic number,也就无法推知有 bug 的代码是哪块。这和我们最初发现的 core 的现象相互印证,很可能被非法修改的内容仅为 4 个字节(int32_t 大小)。


另外,虽然我们加宽了检查边界,程序还是会 core 到 glibc malloc 的元数据处,而不是我们添加的边界里。而且,我们总可以观察到前一块内存(图中蓝色所示)的结尾时完整的,没被破坏。这说明,这不是简单的内存访问超出边界导致的越界。我们可以大胆的做一下猜测:要么是一块已经释放的内存被非法重用了;要么这是通过野指针“空投”过来的一次内存修改。


如果我们的猜测是正确的,那么我们用这种添加内存边界的方式检查内存问题的方法几乎必然是无效的。


打怪利器 electric-fence


至此,我们知道某个时间段内某个变量的内存被其他线程非法修改了,但是却无法定位到是哪个线程哪段代码。这就好比你明明知道未来某个时间段在某个地点会发生凶案,却没办法看到凶手。无比郁闷。


有没有办法能检测到一个内存地址被非法写入呢?有。又一个大名鼎鼎的内存调试库 electric-fence(简称 efence)就华丽登场了。


使用 MALLOC_CHECK_或者 magic number 的方式检测的最大问题是,这种检查是“事后”的。在多线程的复杂环境中,如果不能发生破坏的第一时间检查现场,往往已经不能发现罪魁祸首的蛛丝马迹了。


electric-fence 利用底层硬件(CPU 提供的虚拟内存管理)提供的机制,对内存区域进行保护。实际上它就是使用了下一节我们要自己编码使用的 mprotect 系统调用。当被保护的内存被修改时,程序会立即 core 掉,通过检查 core 文件的 backtrace,就容易定位到问题代码。


这个库的版本有点混乱,容易弄错。搜索和下载这个库时,我才发现,electric-fence 的作者也是大名鼎鼎的 busybox 的作者,牛人一枚。但是,这个版本在 linux 上编译连接到我的程序的时候会报 WARNING,而且后面执行的时候也会出错。后来,找到了 debian 提供的一个更高版本的库,估计是社区针对 linux 做了改进。


使用 efence 需要重新编译程序。efence 编译后提供了一个静态库 libefence.a,它包含了能够替代 glibc 的 malloc, free 等库函数的一组实现。编译时需要一些技巧。首先,要把-lefence 放到编译命令行其他库之前;其次,用-umalloc 强制 g++从 libefence 中查找 malloc 等本来在 glibc 中包含的库函数:


 g++ -umalloc –lefence …
复制代码

用 strings 来检查产生的程序是否真的使用了 efence:



和很多工具类似,efence 也通过设置环境变量来修改它运行时的行为。通常,efence 在每个内存块的结尾放置一个不可访问的页,当程序越界访问内存块后面的内存时,就会被检测到。如果设置 EF_PROTECT_BELOW=1,则是在内存块前插入一个不可访问的页。通常情况下,efence 只检测被分配出去的内存块,一个块被分配出去后 free 以后会缓存下来,直到一下次分配出去才会再次被检测。而如果设置了 EF_PROTECT_FREE=1,所有被 free 的内存都不会被再次分配出去,efence 会检测这些被释放的内存是否被非法使用(这正是我们目前怀疑的地方)。但因为不重用内存,内存可能会膨胀地很厉害。


我使用上面 2 个标记的 4 种组合运行我们的程序,遗憾的是,问题无法复现,efence 没有报错。另外,当 EF_PROTECT_FREE=1 时,运行一段时间后,MergeServer 的虚拟内存很快膨胀到 140 多 G,导致无法继续测试下去。又进入了一个死胡同。


终极神器 mprotect + backtrace + libsigsegv


electric-fence 的神奇能力实际上是使用系统调用 mprotect 实现的。mprotect 的原型很简单,


int mprotect(const void *addr, size_t len, int prot);
复制代码

mprotect 可以使得[addr,addr+len-1]这段内存变成不可读写,只读,可读写等模式,如果发生了非法访问,程序会收到段错误信号 SIGSEGV。


但 mprotect 有一个很强的限制,要求 addr 是页对齐的,否则系统调用返回错误 EINVAL。这个限制和操作系统内核的页管理机制相关。



如图,我们已经知道这个动态数组的第 10 个元素会被非法越界修改。review 了代码,发现从这个数组内容初始化完毕以后,到使用这个数组内容这段时间,不应该再有修改操作。那么,我们就可以在数组内容被初始化之后,立即调用 mprotect 对其进行只读保护。


尝试一


因为 mprotect 要求输入的内存地址页对齐,所以我修改了动态数组的实现,每次申请内存块的时候多分配一个页大小,然后取页对齐的地址为第一个元素的起始位置。



如上图,浅蓝色部分为为了对齐内存地址而做的 padding。代码见下



动态数组申请的最小内存块的大小为 64KB。这里,动态数组中每个元素的大小为 80 字节,我们只需要从第 1 个元素开始保护一个页的大小即可:



既然这个保护区域是程序中自动插入的,需要在内存释放给系统前回复它为可读写,否则必然会因 mprotect 产生段错误。



好了,编译、重启、运行重现脚本。悲剧了。程序运行了很久都不再出 core 了,无法复现问题。我们在分配动态数组内存时,为了对齐在内存块前添加的 padding 导致程序运行时的内存分布和原来产生 core 的运行环境不同了。这可能是无法复现的原因。要想复现,我们不能破坏原来的内存分配方式。


尝试二


不改变动态数组的内存块申请方式,又要满足 mprotect 保护的地址必须页对齐的要求,怎么做呢?我们换一个思路,从第 10 个元素向前,找到包含它且离它最近的页对齐的内存地址。如下图



但这样会造成一个问题。图中浅蓝色部分本不是这个动态数组对象所拥有的内存,它可能被其他任何线程的任何数据结构在使用。我们使用这种方式保护红色区域,会有很多无关的落入蓝色区域的修改操作导致 mprotect 产生段错误。


实验了一下,果然,程序跑起来不久就在其他无关的代码处产生了段错误。这种保护方式的代码如下:



成功


在上一节的保护方式下,我们因为保护了无关内存区域,会导致程序过早产生 SIGSEGV 而退出。我们能否截获信号,不让程序在非法访问 mprotect 保护区域后仍然能继续执行呢?当然。我们可以定制一个 SIGSEGV 段错误信号的处理函数。在这个处理函数中,如果能打印段错误时候的当前调用栈,就可以找到罪魁祸首了。



代码如上图。注意,处理 SIGSEGV 的 handler 函数有一些小技巧(坑很多):


\1. SIGSEGV 一般是内核处理的(page fault)。使用库 libsigsegv 可以简化用户空间撰写处理函数的难度。


\2. 处理函数中,不能调用任何可能再分配内存的函数,否则会引起 double fault。例如,在这段处理函数中,使用 open 系统调用打开文件,不能使用 fopen;buff 是从栈上分配的,不能从 heap 上申请;不能使用 backtrace_symbols,它会向 glibc 动态申请内存,而要使用安全的 backtrace_symbols_fd 把 backtrace 直接写入文件。


\3. 最重要的,在 SIGSEGV 的处理函数中,我们需要恢复引起段错误的内存块为可读写的。这样,当处理函数返回被中断的代码继续执行时,才不能再次引起段错误。重新编译代码,运行重现脚本。查看记录了 backtrace 的文件 sigsegv.bt,我们看到了熟悉的被篡改的指针地址(一半为 0):



这个段错误会最终导致程序 core 掉,因为这个 SIGSEGV 信号不是由我们使用 mprotect 的保护而产生的。查看 core 文件,可以查到被越界的内存(即 ptr_)的地址。从 sigsegv.bt 文件中查找,果然找到了那一次非法访问:



使用 addr2line 检查上面这个调用栈中的地址,我们终于找到了它。又经过一番代码 review 和验证,才总算确定了错误原因。有一个动态 new 出来的对象的指针在两个有关联的线程中共享,在某种极端情况下,其中一个 delete 了对象之后,另一个线程又修改了这个对象。


小结


小结一下,遇到棘手的内存越界问题,可以使用下面顺序逐个尝试:


\1. code review 分析代码。


\2. valgrind 用起来最简单,几乎是傻瓜式的。能用尽量用。


\3. glibc 的 MALLOC_CHECK_使用起来和很简单,不需要重现编译代码。可以用来发现问题,但是其本身无法定位问题。和 magic number 结合起来,可以用来定位一类内存越界的问题。


\4. 和 electric-fence 齐名的还有一个内存调试库叫做 dmalloc。虽然在本次解决问题的过程中没有用到,这个库对于检测内存泄露等其他问题很有用。推荐大家学习一下,放到自己的工具库中。


\5. electric-fence 是定位一类“野指针”访问问题的利器,强烈推荐使用。


\6. 如果上述所有工具都帮不了你,那么只好在熟悉代码逻辑的基础上,使用终极武器了。


\7. code review。通过尝试代码库中不同版本编译出来的程序复现 bug,用二分法定位引入 bug 的最早的一次代码提交。


更多 SQL 相关技术内容,请关注公号:Java 架构师联盟


发布于: 2020 年 11 月 13 日阅读数: 47
用户头像

小Q

关注

还未添加个人签名 2020.06.30 加入

小Q 公众号:Java架构师联盟 作者多年从事一线互联网Java开发的学习历程技术汇总,旨在为大家提供一个清晰详细的学习教程,侧重点更倾向编写Java核心内容。如果能为您提供帮助,请给予支持(关注、点赞、分享)!

评论

发布
暂无评论
linux下定位多线程内存越界问题实践总结