当前位置: 首页 > Linux

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

时间:2023-04-06 18:33:37 Linux

最近定位一个多线程服务器程序(OceanBaseMergeServer),一个线程非法篡改另一个线程的内存,导致内核程序的失败。定位这个问题历经波折,尝试了各种内存调试方法。常常觉得美好的未来即将浮现,却发现自己已经进入了另一个死胡同。最终使用mprotect+backtrace+libsigsegv等强大工具成功定位问题。整个定位过程中遇到的问题和解决办法,都是比较典型的多线程内存越界问题,所以我简单总结一下,分享给大家。现象核心是在系统集成测试中发现的。服务器程序MergeServer有一个由50个工作线程组成的线程池。使用8线程的测试程序通过MergeServer读取数据时,后者偶尔会出核。用gdb查看core文件,发现core的原因是某个指针的地址不合法。当进程访问指针指向的地址时,会导致段错误(segmentfault)。见下文。越界指针ptr_位于一个名为cname_的对象中,该对象是动态数组field_columns_的第10个元素的成员。如下所示。重现问题后,折腾了2天,终于找到重现问题的方法。重复多次,可以观察到以下现象:随着客户端并发数的增加(从8线程增加到16线程),产生核心的概率增加;减少服务器端线程池中的线程数(从50个减少到2个),核心无法复现。被篡改的指针总是有一半(高4字节)变为0,而另一半看起来是正确的。请参考上一节,复现多次,每次释放内核,都是因为field_columns_动态数组的第10个元素data_[9]的cname_成员的ptr_成员被篡改了。这是一个不好解释的奇怪现象。在代码中插入一个检查点,从最开始生成field_columns_中的内容到导致越界读取的代码序列中的“埋点”,即使用二分查找的方式定位到越界读取的代码位置篡改cname_。事实证明,程序有时在检查点之前进行内核处理,有时在检查点之后进行内核处理。根据以上现象,初步判断这是多线程程序中的内存越界问题。使用glibc的MALLOC_CHECK_因为是内存问题,考虑使用一些内存调试工具来定位问题。因为OB有自己的内存块缓存,需要去掉它的影响。修改OB内存分配器,让它每次直接调用c库的malloc和free,不用缓存。然后您可以使用glibc的内置内存块完整性检查功能。利用这个特性,程序不需要重新编译,运行时只需要设置环境变量MALLOC_CHECK_即可(注意末尾的下划线)。每当在程序运行期间为glibc提供空闲内存时,glibc将检查其隐藏元数据的完整性,并在发现错误时立即中止。使用类似下面的命令行启动服务器程序:exportMALLOC_CHECK_=2bin/mergeserver-z45447-r10.232.36.183:45401-p45441使用MALLOC_CHECK_后,程序核心在不同的位置。调用free时,glibc检查内存块之前的验证头错误而中止。如下所示。但是这个核心能给我们带来的信息很少。我们只是找到了另一种更有效地重现问题的方法。或许最开始看到的核心现象只是延迟出现,但实际上,记忆在“更早”的时刻被破坏了。valgrindglibc提供的MALLOC_CHECK_函数太简单了。有没有更高级的工具,既能报错,又能分析问题原因?我们很自然地想到了大名鼎鼎的valgrind。使用valgrind检查内存问题,程序不需要重新编译,直接使用valgrind启动即可:nohupvalgrind--error-limit=no--suppressions=suppressbin/mergeserver-z45447-r10.232.36.183:45401-p45441>nohup.out&默认情况下,当valgrind发现1000个不同的错误,或者总数超过1000万个错误时,它将停止报告错误。可以通过添加--error-limit=no来禁用此功能。--suppressions用于掩盖一些不关心的误报。折腾了一番,核心问题无法用valgrind重现。valgrind报错也是误报,与问题无关。可能是因为valgrind运行程序会使程序性能降低10倍以上,影响多线程程序运行的时序,导致核心无法重现。这种方式行不通。需要C/C++Linux高级服务器架构师学习资料加群812855908(含C/C++、Linux、golang技术、Nginx、ZeroMQ、MySQL、Redis、fastdfs、MongoDB、ZK、流媒体、CDN、P2P、K8S、Docker、TCP/IP、coroutine、DPDK、ffmpeg等)magicnumber既然MALLOC_CHECK_可以检测到程序的内存问题,那么我们其实想知道是谁(哪段代码)越界了。这时,我们想到了用magicnumberpadding来标记数据结构。如果我们在越界内存中看到一个幻数,我们就知道是哪一段代码出了问题。首先修改对malloc的封装函数,给返回给用户的内存块填充一个特殊的值(这里是0xEF),并且在开始和结束的时候申请24个字节,同样填充一个特殊的值(起始0xBA,结束0xDC)。另外,我们在预留内存块的头部的后8个字节存储当前线程的ID,这样一旦观察到越界,我们就可以判断是哪个线程越界了。代码示例如下。然后,当用户程序通过我们的freeentry释放内存时,检查我们填充到边界的magicnumber。同时调用mprobe强制glibc对内存块进行完整性检查。最后,将幻数添加到程序中所有可疑的关键数据结构,以便在调试器检查内存时可以识别它们。比如,嗯,都加了。使用MALLOC_CHECK_重新运行。程序如愿再次出核,检查越界位置的内存:如上图,红色部分是我们自己填充的越界检查头,可以看到它没有被摧毁。确认第二行存储的线程号等于我们当前线程的线程号。蓝色部分是前面动态内存分配的结束,也完成了(24字节0xdc)。0x44afb60和0x44afb68这两行所示的内存是glibcmalloc存放自己元数据的地方。程序核心丢失的原因是在检查这两行内容的完整性时发现错误。可以推断,被非法篡改的内容小于16字节。仔细观察这16个字节的内容,并没有看到熟悉的magicnumber,所以无法推断是哪段代码存在bug。这和我们一开始发现的core现象相互印证,很可能非法修改的内容只有4个字节(int32_t大小)。此外,虽然我们加宽了检查边界,但程序仍然会以glibcmalloc的元数据为核心,而不是我们添加的边界。而且,我们总能观察到前一块内存的末尾(图中蓝色部分)是完整的,没有被破坏。这说明这不是简单的内存访问越界导致的。我们可以大胆猜测:要么是一块被释放的内存被非法重用;或者这是野指针“空投”的内存修改。如果我们的猜测是正确的,那么我们这种通过添加内存边界来检查内存问题的方法几乎可以肯定是无效的。杀怪工具electric-fence至此,我们知道某个变量的内存在某个时间段内被其他线程非法修改了,但是我们无法定位到是哪个线程,是哪段代码。就好比你知道未来某个时间段某个地方会发生凶杀案,但是你却看不到凶手。很郁闷。有没有办法检测内存地址是否被非法写入?有。另一个知名的内存调试库electric-fence(简称efence)登场了。MALLOC_CHECK_或幻数检测的最大问题是这种检查是“事后”的。在多线程的复杂环境中,如果在发生损坏时不能第一时间检查现场,往往无法找到肇事者的蛛丝马迹。Electric-fence利用底层硬件提供的机制(CPU提供的虚拟内存管理)来保护内存区域。它实际上使用了我们将在下一节中编写的mprotect系统调用。当被保护的内存被修改时,程序会立即被核心化。通过查看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...使用字符串检查生成的程序是否真的使用了efence:与很多工具类似,efence也修改它的运行时行为通过设置环境变量。通常,efence会在每个内存块的末尾放置一个不可访问的页面,当程序越界访问该块后面的内存时,就会检测到这一点。如果设置了EF_PROTECT_BELOW=1,则在内存块之前插入一个不可访问的页面。通常,efence只检测分配的内存块。一个block被分配后,free后会被缓存起来,直到下次分配时才会被再次检测到。而如果设置EF_PROTECT_FREE=1,所有释放的内存都不会再分配,efence会检测释放的内存是否被非法使用(这是我们目前怀疑的)。但是因为内存没有被复用,内存可能会膨胀很多。我使用上述2个标志的4种组合运行我们的程序。不幸的是,问题无法复现,efence也不报错。另外,EF_PROTECT_FREE=1时,MergeServer运行一段时间后,虚拟内存迅速膨胀到140多G,无法继续测试。又一个死胡同。终极神器mprotect+backtrace+libsigsegvelectric-fence的神奇能力其实是使用系统调用mprotect实现的。mprotect的原型很简单,intmprotect(constvoid*addr,size_tlen,intprot);mprotect可以使[addr,addr+len-1]的内存变为不可读、只读、可读等多种模式,如果发生非法访问,程序会收到段错误信号SIGSEGV。但是mprotect有很强的限制,要求addr是页对齐的,否则系统调用返回错误EINVAL。这个限制与操作系统内核的页面管理机制有关。如图,我们已经知道这个动态数组的第10个元素会被非法越界修改。查看代码后,发现数组内容的初始化和数组内容的使用之间应该没有修改操作。然后,我们可以在数组内容初始化后立即调用mprotect来保护它不被只读。尝试一:因为mprotect要求输入的内存地址是页对齐的,所以我修改了动态数组的实现。每申请一个内存块,我就额外分配一个页大小,然后把页对齐的地址作为第一个元素的起始位置。如上图,浅蓝色部分是用于对齐内存地址的padding。代码如下所示。动态数组申请的最小内存块大小为64KB。这里,动态数组中每个元素的大小为80字节,我们只需要从第一个元素开始保护一页的大小即可:由于这个保护区域是程序自动插入的,需要在内存释放前释放释放给系统回复为可读可写,否则必然会因为mprotect造成segmentationfault。好的,编译,重启,然后运行重现脚本。悲剧。程序运行了很长时间,没有释放内核,无法复现问题。我们分配动态数组内存的时候,为了对齐内存块前面加的padding,程序运行时的内存分配和原来生成core的运行环境是不一样的。这可能就是无法复现的原因。要重现,我们不能破坏原来的内存分配方式。Try2.不改变动态数组的内存块申请方式,同时满足mprotect保护的地址必须是页对齐的要求。怎么做?我们换个思路,从第10个元素开始,找到包含它并且离它最近的页对齐内存地址。如下所示,但这会导致问题。图中浅蓝色部分不是这个动态数组对象拥有的内存,它可能被任何其他线程的任何数据结构使用。我们用这种方法保护红色区域,会有很多不相关的修改操作落入蓝色区域,导致mprotect产生segmentationfault。经过实验,果然,程序运行不久就在其他不相关的代码中产生了segmentationfault。这种保护方式的代码如下:在上一节的保护方式中,我们保护了不相关的内存区域,这会导致程序过早产生SIGSEGV并退出。非法访问mprotect保护区后,能否拦截信号,阻止程序继续执行呢?当然。我们可以自定义一个SIGSEGV段故障信号处理函数。在这个处理函数中,如果能在发生segmentfault时打印出当前的调用栈,就可以找到罪魁祸首。代码如上所示。注意处理SIGSEGV的handler函数有一些tricks(陷阱很多):SIGSEGV一般由kernel处理(pagefault)。使用库libsigsegv可以简化在用户空间编写处理函数的难度。在处理函数中,不能调用任何可能重新分配内存的函数,否则会造成doublefault。比如在这个处理函数中,使用open系统调用打开文件,不能使用fopen;buff是从栈中分配的,不能从堆中申请;backtrace_symbols不能用,它会动态向glibc申请内存,使用safebacktrace_symbols_fd将backtrace直接写入文件。最重要的是,在SIGSEGV的处理函数中,我们需要将导致segmentationfault的内存块恢复为可读可写。这样当处理函数返回到被中断的代码继续执行时,就不会再造成段错误了。重新编译代码并运行复制脚本。查看记录backtrace的文件sigsegv.bt,我们看到熟悉的篡改指针地址(其中一半为0):这个段错误最终会导致程序的核心,因为SIGSEGV信号不是保护产生的我们使用mprotect。查看core文件,可以找到越界内存的地址(即ptr_)。从sigsegv.bt文件中查找,发现非法访问:使用addr2line查看上面调用栈中的地址,终于找到了。经过一番代码审查和验证,最终确定了错误的原因。有一个指向两个关联线程之间共享的动态新对象的指针。在某些极端情况下,其中一个线程删除对象后,另一个线程修改了该对象。总结总结一下,如果你遇到棘手的内存越界问题,可以按照以下顺序一一尝试:codereview分析代码。valgrind是最容易使用的,几乎是万无一失的。尽可能多地使用。glibc的MALLOC_CHECK_使用简单,不需要重新编译代码。它可以用来发现问题,但它不能自己定位问题。结合magicnumber,可以用来定位一类内存越界问题。和electric-fence一样出名的是一个叫做dmalloc的内存调试库。虽然在这个问题解决过程中没有用到,但这个库对于检测内存泄漏等其他问题很有用。建议大家学习一下,放到自己的工具库中。electric-fence是定位一类“野指针”访问问题的利器,强烈推荐使用。如果以上工具都帮不了你,那你就得在熟悉代码逻辑的基础上使用终极武器了。代码审查。通过在代码库中尝试从不同版本编译的程序来重现错误,并使用二分法定位最早引入错误的代码提交。