IT技术博客大学习 共学习 共进步

近期Imgsrc一处内存泄露问题的查找和解决

淘宝核心系统团队博客 2012-10-14 22:06:44 浏览 6,663 次

    最近一直在查我们的imgsrc的内存泄露问题,事实上都是其所使用的ImageMagick库的bug。前些天又查了一个bug,涉及面较广,觉得有必要总结一下。

    简要说明一下,imgsrc上部署的是apache模块,cdn通过其来访问tfs,并且做一些图像处理工作。有内存泄露是在线上发现的,内存不停的在涨。要找到问题所在,首先需要能够在线下重现,知道在什么情况下会泄露。线上系统当然不可能用valgrind来跑啦,还好我们有tcpcopy(赞一下网易的 @wangbin579 同学,真是个好东西),我们可以将线上流量镜像到跑着valgrind的机器上来,从而重现问题。跑了一晚上之后问题重现了,这时候需要做的是找到具体能触发问题的http请求。在访问日志和错误日志的帮助下,可以重放这些请求,这样就可以随时重现。一个晚上的访问日志有80多万条之多,我注意到其中有43条是在做图像处理时失败的。这些请求的原图往往都是一些不合法的或已损坏的图。先从这43个请求入手,运气不错,问题已经重现了。这告诉我们多注意一下不法分子总是好的。接下来就是使用2分法来找到具体的某一个访问,可以用脚本来干。最终确定是一个png图片。

    经常用valgrind的人肯定知道,有时候打印出来的堆栈是不全的,会有一些是???。这次我也遇到了同样的问题,有人说用addr2line可以看到,试了一下无果。就上网查了下valgrind打印堆栈不全的原因,在这里有描述:http://valgrind.org/docs/manual/faq.html#faq.unhelpful

    如果检查的程序是共享库,如果这个共享库在程序退出前被unload,那么valgrind会把它的debug信息给抛弃,导致调用堆栈那里会变成???的记录。解决方法是不调用dlclose。

    首先是尝试将httpd源码apr库中的dso模块调用dlclose的地方注释掉,无果。想到这些堆栈还是有打出来的,之前没打出来的是ImageMagick的编解码库的堆栈,因此在ImageMagick的代码中搜了一下,果然有调用dlclose的地方。这个还不好改,尝试了几种方法(它有一个平台独立的ltdl库,由于调用dlclose的地方太多,我首先想到将dlclose函数给替换成一个空函数)都无果,甚至还导致进程不能正常启动。后来想到应该使改动尽可能小,就用SystemTap查看了一下kill httpd的过程中IamgeMagick调了dlclose的地方,然后将这两个地方的dlclose给注释掉,这样valgrind就打全调用堆栈了。

    现在可以看到是具体哪个资源没有释放了,但是还不知道在什么情况下这个资源没有释放。

    经过初步调试,可以断定问题出在循环读图片的行列像素这个过程中,现在的问题在于到底是这个过程中哪个函数、何时(哪次循环)出来的?首先得确定何时出来,然后在那个点调试就能知道是哪个函数出来的了。由于循环次数很多(800次),通过修改代码,在每次循环时都打一行日志到一个文件里头,这样就找到了出问题的循环点。然后在gdb中直接跳过正常循环,在那次循环过程中单步,确定了出问题的函数:这是一个libpng库里头的函数png_read_row,在执行完它之后就跟踪不了了。首先想到的是这个函数是否抛异常了,google了一下搜到一个我朝网友向ImageMagick反应的读png图时内存泄露的问题(http://www.imagemagick.org/discourse-server/viewtopic.php?f=3&t=20522),这哥们看来和我一样苦逼,不过这个哥们比我牛逼一点,知道png_read_row之后会跑到什么地方执行。事实上,他所反映的这个问题和我遇到的是同一个问题,不过是泄露的资源不同,ImageMagick的开发者们也不吸取教训。

    ImageMagick(事实上是因为libpng这么要求)这里是使用setjmp/longjmp的机制来在读到损坏的png图片时释放资源的。顺便普及一下setjmp/longjmp机制:

    这是C函数库提供用来全局跳转用的(goto只能函数内跳转),通常用于错误处理。setjmp函数用来设置一个跳转点,之后调用longjmp就能从其他函数跳转到刚刚调用setjmp的地方。setjmp函数有两种返回值,直接调用时会返回0,如果是从longjmp返回的则会返回非0。在实现上,setjmp会将当前栈的上下文信息保存在其参数(一个jmp_buf数据结构)中,然后longjmp会恢复指定的jmp_buf所保存的栈上下文信息,从刚刚调用setjmp的地方继续执行。注意,如果调用setjmp的函数返回了,那么保存的栈上下文信息就失效了。

    ImageMagick在解码png前调用了setjmp(png_jmpbuf(ping)),这个png_jmpbuf应该是libpng库里面的一个全局变量。因此,可以推测libpng在其函数png_read_row中,如果遇到解码失败的情况,会调用longjmp(png_jmpbuf, 1),以便库的使用者进行错误处理。

    我在setjmp返回非0的代码中设了个断点,果然进来了,调试发现虽然代码里面有释放资源的代码,但是并没有执行。怀疑是gcc O2优化的问题,修改ImageMagick的优化级别(改成O0),内存泄露就没有了。再仔细看了下代码,就发现问题了,gcc把这行释放资源的代码给优化掉了。代码如下:

  • unsigned char
  •     *ping_pixels;
  •   ping_pixels=(unsigned char *) NULL;
  •   if (setjmp(png_jmpbuf(ping)))
  •     {
  •       /*
  •         PNG image is corrupt.
  •       */
  •       png_destroy_read_struct(&ping,&ping_info,&end_info);
  • #ifdef PNG_SETJMP_NOT_THREAD_SAFE
  •       UnlockSemaphoreInfo(ping_semaphore);
  • #endif
  •       if (ping_pixels != (unsigned char *) NULL)
  •         ping_pixels=(unsigned char *) RelinquishMagickMemory(ping_pixels);
  •     这里ping_pixels就是引起泄露的资源,是在这之后的代码中分配的,因此一开始初始化为NULL,作者原意是要在之后如果通过longjmp回来之后对其进行释放。关键就在这里,longjmp返回之后,ping_pixels的值是多少?查看了相关资料,并且通过做简单的小实验之后总结如下:

        放在内存中的变量,在longjmp返回时,仍然是调用longjmp这时候的值;而如果是放在寄存器中的变量,通过longjmp返回的时候,它的值会恢复成原来setjmp的时候的值。

        这在《UNIX环境高级编程》一书中讲得非常清楚。

        因此,这里我目前的解决方法是将ping_pixels变量的值变成volatile,这样gcc在优化时就不会将其放到寄存器中,也就不会在longjmp返回的时候恢复其为NULL。这里注意应该使用

        unsigned char  * volatile ping_pixels,而不是 volatile unsigned char* ping_pixels。

        经过验证,这样修改之后,这个内存泄露就修复了。

    建议继续学习

    1. 了解前端内存泄露 (阅读 5,482)
    2. GLIBC内存分配机制引发的“内存泄露” (阅读 5,103)
    3. GC与JS内存泄露 (阅读 4,604)
    4. 一个 Lua 内存泄露检查工具 (阅读 3,885)
    5. PHP内存耗尽错误分析 (阅读 3,083)
    6. Android应用内存泄露分析、改善经验总结 (阅读 2,384)
    7. 记一次内存泄露的debug过程 (阅读 2,303)