0
0

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

明俨 发表于 2012年10月14日 20:21 | Hits: 1750
Tag: BugFix | TFS

最近一直在查我们的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把这行释放资源的代码给优化掉了。代码如下:

  1. unsigned char
  2.     *ping_pixels;
  3.   ping_pixels=(unsigned char *) NULL;

  4.   if (setjmp(png_jmpbuf(ping)))
  5.     {
  6.       /*
  7.         PNG image is corrupt.
  8.       */
  9.       png_destroy_read_struct(&ping,&ping_info,&end_info);

  10. #ifdef PNG_SETJMP_NOT_THREAD_SAFE
  11.       UnlockSemaphoreInfo(ping_semaphore);
  12. #endif

  13.       if (ping_pixels != (unsigned char *) NULL)
  14.         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。

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

原文链接: http://rdc.taobao.com/blog/cs/?p=1651

0     0

我要给这篇文章打分:

可以不填写评论, 而只是打分. 如果发表评论, 你可以给的分值是-5到+5, 否则, 你只能评-1, +1两种分数. 你的评论可能需要审核.

评价列表(0)