定位多线程内存越界问题实践总结
2013/2/4
杨志丰 ***********************
关键字 多线程,内存越界,valgrind,electric-fence,mprotect,libsigsegv,glibc
最近定位了在一个多线程服务器程序(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掉了。如下图。
merge函数    但这个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不能复现。此路不通。
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的作者,牛人一枚。原作者的上的下载地址为perens/FreeSoftware/ElectricFence/。但是,这个版本在linux上编译连接到我的程序的时候会报WARNING,而且后面执行的时候也会出错。后来,到了debian提供的一个更高版本的库,估计是社区针对linux做了改进。我最后用的是这个2.2.4版本:/sid/electric-fence

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。