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

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

现象

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

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

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

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

复现问题

以后,花了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掉了。以下图。多线程

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

但这个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高级服务器架构师学习资料后台加群812855908(包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等)

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

magic number

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

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

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

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

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

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

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

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

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

如上图,红色部分是咱们本身填充的越界检查头部,能够看到它没有被破坏。其中第二行存储的线程号通过确认确实等于咱们当前线程的线程号。

蓝色部分为前一个动态内存分配的结尾,也是完整的(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:

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

和不少工具相似,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。这个限制和操做系统内核的页管理机制相关。

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

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

尝试一

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

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

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

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

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

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

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

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

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

尝试二

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

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

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

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

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

成功

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

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

代码如上图。注意,处理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):

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

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

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

使用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的最先的一次代码提交。
相关文章
相关标签/搜索