
解决多线程内存问题的实践总结
2013/2/4
杨志峰
关键字多线程,内存越界,valgrind,电子栅栏,mprotect,libsigsegv,glibc
最近,在多线程服务器程序(OceanBaseMergeServer)中,一个线程非法篡改了另一个线程的内存,并导致程序核心掉线. 找到整个问题花了整整一周的时间. 在曲折的时期,尝试了各种内存调试方法. 通常感觉天空的意志会变得清晰,但是他们发现自己已经进入了另一个死胡同. 最后,使用功能强大的工具(例如mprotect + backtrace + libsigsegv)成功定位问题. 在整个定位过程中遇到的问题和解决方案是多线程内存跨界问题的典型问题,我将简要总结并与您分享. 对于只对最终组合秘诀感兴趣的学生,请直接阅读最后一节. 其他各章也写在这里供大众科学使用.
现象
核心是在系统集成测试期间发现的. 服务器程序MergeServer具有一个由50个工作线程组成的线程池. 当使用8个线程的测试程序通过MergeServer读取数据时,后者有时会成为核心. 使用gdb查看核心文件并发现导致核心的原因是指针的地址非法. 当进程访问指针所指向的地址时,将导致段错误(segmentfault). 见下文.

越界指针ptr_位于名为cname_的对象中,并且该对象是动态数组field_columns_的第十个元素的成员. 如下所示.

重现问题
此后,花了2天的时间终于找到了重现该问题的方法. 重复多次,可以观察到以下现象:
1. 随着并发客户端数量的增加(从8个线程增加到16个线程),核心的可能性也随之增加;
2. 将服务器端线程池中的线程数从50个减少到2个,并且无法复制核心.
3. 对于被篡改的指针,指针的一半(高4个字节)总是更改为0,而另一半似乎是正确的.
4. 请查看上一节并重现多次. 每次输出内核时,这是因为field_columns_动态数组的第十个元素data_ [9]的cname_成员的ptr_成员已被篡改. 这是一个很难解释的奇怪现象.
5. 将一个检查点插入代码,从field_columns_中内容的初始生成到导致读取越界的代码序列中的“埋点”,后者使用二进制搜索来查找被篡改的代码位置cname_. 事实证明,该程序有时核心位于检查点之前,有时核心位于检查点之后.
基于上述现象,初步判断这是一个多线程程序中的内存越界问题.
使用glibc的MALLOC_CHECK _
由于这是内存问题,请考虑使用一些内存调试工具来查找问题. 由于OB具有自己的存储块缓存,因此需要消除其影响. 修改OB内存分配器,使其每次都直接调用malloc并释放c库,而无需进行缓存. 然后,您可以使用glibc的内置内存块完整性检查功能.

使用此功能,无需重新编译程序,只需在运行时设置环境变量MALLOC_CHECK_(请注意下划线). 在程序运行过程中,只要给glibc释放了可用内存,glibc都会检查其隐藏元数据的完整性,并在发现错误时立即终止.
使用类似于以下内容的命令行启动服务器程序:
exportMALLOC_CHECK_ = 2
bin / mergeserver-z45447-r10.232.36.183: 45401-p45441
使用MALLOC_CHECK_之后,程序核心转到了另一个位置. 调用free时,glibc会检查内存块前面的check header错误并中止它. 如下所示.

但是这个核心带给我们的信息很少. 我们只是找到了另一种稍微更有效地重现该问题的方法. 也许我最初看到的核心现象是延迟表现,但记忆在“较早”的时刻就被破坏了.
valgrind
glibc提供的MALLOC_CHECK_函数太简单了. 是否有更高级的工具不仅可以报告错误,还可以分析问题的原因?我们自然想到了著名的瓦尔格朗德. 使用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倍以上,这将影响多线程程序的运行时间,从而导致内核无法重现. 这条路无处.
魔术数
由于MALLOC_CHECK_可以检测程序的内存问题,因此我们实际上想知道是谁(哪段代码)越界了. 在这一点上,我们考虑使用magicnumber填充来指示数据结构. 如果我们在越界内存中看到某个幻数,那么我们知道是哪段代码.
首先,修改malloc的封装函数,以使用特殊值(此处为0xEF)填充返回给用户的内存块,并在开头和结尾处申请额,就可以确定哪个线程超出范围. 代码示例如下.

然后,当用户程序通过自由条目释放内存时,我们将检查填充到边界的幻数. 同时,调用mprobe强制glibc对内存块执行完整性检查.

最后,将magicnumber添加到程序中所有可疑的键数据结构中,以便在调试器中检查内存时可以识别它们. 例如


好,全部添加. 使用MALLOC_CHECK_重新运行. 程序核心再次下降,检查内存在越界位置:

如上图所示,红色部分是我们自己装满的越界检查头,我们可以看到它没有损坏. 确定存储在第二行中的线程号确实等于我们当前线程的线程号. 蓝色部分是前一个动态内存分配的结尾,该分配也已完成(24字节0xdc). 两行0x44afb60和0x44afb68中显示的内存是glibcmalloc存储自己的元数据的地方. 程序核心掉线的原因是,在检查这两行的完整性时发现了错误. 由此推断,非法篡改的内容小于16个字节. 仔细观察这16个字节的内容,我们看不到熟悉的magicnumber,因此无法推断出带有错误的代码. 我们最初发现的核心现象相互证实了这一点. 非法修改的内容可能只有4个字节(int32_t大小).
此外,尽管我们扩大了检查边界,但该程序仍将以glibcmalloc元数据为核心,而不是我们添加的边界. 此外,我们总是可以观察到前一段内存的末尾(图中蓝色显示)是完整的,没有被破坏. 这表明,这不仅仅是内存访问超出边界导致的越界. 我们可以大胆地猜测: 要么已释放的一段内存被非法重用;要么被释放. 还是这是通过野指针“空投”的内存修改.
如果我们的猜测是正确的,那么我们添加内存边界以检查内存问题的方法几乎肯定是无效的.
电子围栏,一种与怪物战斗的武器
到目前为止,我们知道某个变量的内存在一定时间内已被其他线程非法修改,但是我们无法找到哪个线程和哪个代码段. 就像您知道将来某时某地会发生谋杀案,但您看不到凶手. 很沮丧.
是否有办法检测到内存地址已被非法写入?有. 另一个著名的内存调试库电子围栏(称为efence)在这里. 使用MALLOC_CHECK_或magicnumber进行检测的最大问题是此检查是“事后”. 在复杂的多线程环境中,如果您尽快检查场景而不会造成损坏,则通常将找不到罪魁祸首的线索.
电子围栏使用底层硬件提供的机制(CPU提供的虚拟内存管理)来保护内存区域. 实际上,它使用mprotect系统调用,我们将在下一部分中编写代码. 修改受保护的内存后,该程序将立即进入内核. 通过检查核心文件的回溯,可以轻松找到问题代码.
该库的版本有点混乱,容易出错. 在搜索和下载该库时,我发现电子围栏的作者也是著名的busybox的作者. 原始作者的官方网站上的下载地址为. 但是,此版本将在编译并连接到Linux上的程序时报告警告,并且在以后执行时也会出错. 后来,我找到了Debian提供的更高版本的库. 据估计,社区已经对Linux进行了改进. 我使用的最后一个版本是2.2.4: .
使用efence需要重新编译程序. 编译efence之后,将提供一个静态库libefence.a,其中包含一组可以代替glibc的malloc,free和其他库函数的实现. 编译时需要一些技巧. 首先,在编译命令行上将-lefence放在其他库之前;其次,使用-umalloc强制g ++从libefence查找最初包含在glibc中的malloc和其他库函数:
g ++-umalloc–lefence ...
使用字符串检查生成的程序是否确实使用了效率:

类似于许多工具,efence还通过设置环境变量来修改其运行时行为. 通常,efence在每个存储块的末尾放置一个无法访问的页面,当程序越过边界访问位于该存储块后面的存储器时,将检测到该页面. 如果设置EF_PROTECT_BELOW = 1,则将在内存块之前插入不可访问的页面. 通常,efence仅检测分配的内存块. 分配一个块之后,它将在释放之后被缓存,直到下一次分配它时才会再次被检测到. 如果设置EF_PROTECT_FREE = 1,将不会再次分配所有可用内存,并且efence会检测到是否非法使用了已释放内存(这是我们目前怀疑的位置). 但是因为不重用内存,所以内存可能会大大扩展.
我使用以上2个标记的4种组合来运行我们的程序. 不幸的是,该问题无法重现,并且efence没有报告错误. 此外,当EF_PROTECT_FREE = 1时,运行一段时间后,MergeServer的虚拟内存迅速膨胀到140G以上,这使得无法继续测试. 它又进入了死胡同.
终极神器mprotect + backtrace + libsigsegv
电子围栏的神奇功能实际上是使用mprotect系统调用来实现的. mprotect的原型非常简单,
intmprotect(constvoid * addr,size_tlen,intprot);
mprotect可以使内存的[addr,addr + len-1]节变为不可读,只读,读写和其他模式. 如果发生非法访问,程序将收到分段错误信号SIGSEGV. 但是mprotect有一个严格的限制,要求addr进行页面对齐,否则系统调用将返回错误EINVAL. 此限制与操作系统内核的页面管理机制有关.


如图所示,我们已经知道该动态数组的第10个元素将被非法修改. 查看代码后,我发现在初始化数组内容之后,直到使用数组内容之前,都不应进行任何修改操作. 然后,我们可以在初始化数组内容之后立即调用mprotect使其免受只读保护.
尝试一个
由于mprotect要求输入存储地址的页面对齐,因此我修改了动态数组的实现. 每次我申请一个内存块时,都会分配一个额外的页面大小,然后将页面对齐的地址作为第一个元素的起始位置.

如上图所示,浅蓝色部分被填充以对齐内存地址. 参见下面的代码

动态数组请求的最小存储块大小为64KB. 在这里,动态数组中每个元素的大小为80个字节,我们只需要保护第一个元素的页面大小即可:

由于此保护区是自动插入程序中的,因此在将内存释放到系统之前,需要将其恢复为可读写状态,否则由于mprotect不可避免地会发生分段错误.

好的,编译,重新启动并运行复制脚本. 悲剧. 该程序已运行很长时间,并且核心不再可用,并且问题无法重现. 当我们分配动态数组内存时,为了对齐添加在内存块前面的填充,运行时程序的内存分配与核心的原始操作环境不同. 这可能是无法复制的原因. 为了重现,我们不能破坏原始的内存分配方法.
尝试两个
如何在不更改动态数组的内存块应用方法且不满足必须将mprotect保护的地址进行页面对齐的情况下执行此操作?让我们改变想法,从第十个元素开始,找到包含它的内存地址,并与最接近它的页面对齐. 如下图所示

但这会引起问题. 图片的浅蓝色部分不是此动态数组对象拥有的内存,它可以由任何其他线程的任何数据结构使用. 我们使用这种方法来保护红色区域,并且会有许多无关的修改落入蓝色区域,这将导致mprotect生成分段错误.
我进行了实验,可以肯定的是,程序运行后不久,其他不相关的代码中发生了分段错误. 此保护方法的代码如下:


成功

在上一节的保护模式下,我们保护无关的存储区,这将导致程序过早生成SIGSEGV并退出. 在非法访问mprotect保护区后,我们可以拦截信号并阻止程序继续执行吗?当然. 我们可以自定义SIGSEGV段错误信号处理功能. 在此处理功能中,如果您可以在出现分段错误时打印当前的调用堆栈,就可以找到罪魁祸首.

代码如上所示. 请注意,在处理SIGSEGV的处理函数时有一些技巧(很多陷阱):
1.SIGSEGV通常由内核(pagefault)处理. 使用库libsigsegv可以简化在用户空间中编写处理函数的难度.
2. 在处理函数中,您不能调用任何可能重新分配内存的函数,否则将导致双重错误. 例如,在此处理功能中,使用open系统调用打开文件,而不能使用fopen. buff是从堆栈分配的,不能从堆中申请;您不能使用backtrace_symbols,它将动态地从glibc申请内存,但是要使用安全性backtrace_symbols_fd直接将backtrace写入文件.
3. 最重要的是,在SIGSEGV的处理功能中,我们需要恢复导致分段错误的可读性和可写性的存储块. 这样,当处理功能返回中断的代码以继续执行时,就不会再次引起分段错误.
重新编译代码并运行复制脚本. 查看记录回溯的文件sigsegv.bt,我们看到了被篡改的熟悉的指针地址(一半是0):

此段错误最终将导致程序被内核化,因为此SIGSEGV信号不是由我们使用mprotect的保护生成的. 查看核心文件,您可以找到越界内存的地址(即ptr_). 从sigsegv.bt文件中搜索,我发现了非法访问:

使用addr2line检查上面的调用堆栈中的地址,我们终于找到了它. 在再次检查和验证代码之后,最终确定了错误原因. 动态新对象的指针在两个相关线程之间共享. 在极端情况下,其中一个删除对象后,另一个线程会修改该对象.
摘要
总而言之,如果遇到困难的内存越界问题,可以按以下顺序逐一尝试:
1.codereview分析代码.
2. Valgrind是最容易使用的,几乎是愚蠢的. 尽可能多地使用.
3. glibc的MALLOC_CHECK_使用非常简单,不需要再现已编译的代码. 它可以用来发现问题,但不能定位问题本身. 与magicnumber结合使用,它可以用于定位一种类型的内存越界问题.
4. 还有一个名为dmalloc的内存调试库,它与电子围栏一样著名. 尽管此库未在解决问题的过程中使用,但该库对于检测内存泄漏和其他问题非常有用. 建议您研究它并将其放在您自己的工具库中.
5. 电子围栏是一种用于定位“野生指针”访问问题的强大工具,因此强烈建议使用.
6. 如果以上工具都不能帮助您,那么您必须基于对代码逻辑的熟悉程度使用最终武器.
7.codereview. 通过尝试在代码库中尝试从不同版本编译的程序来重现错误,并使用二分法查找最早引入错误的代码提交.
强迫C ++程序员警告新手,除非您想构建性能特别苛刻的低级系统,否则请在项目中使用Java.
本文来自电脑杂谈,转载请注明本文网址:
http://www.pc-fly.com/a/shoujiruanjian/article-294457-1.html
我舰奉命撞沉你舰