乐趣区

关于c++:linux下定位多线程内存越界问题实践总结

最近定位了在一个多线程服务器程序(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 高级服务器架构师学习材料后盾加群 812855908(包含 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 的最早的一次代码提交。
退出移动版