当前位置: 首页 > 科技观察

一个4小时的内存泄漏问题

时间:2023-03-15 09:04:29 科技观察

上周照例例行查看线上机器性能,突然发现一个服务的内存占用是这样的:很明显是服务存在内存泄漏问题,所以快速解决问题。故障排查首先确定内存泄漏问题发生的时间,发现当时线上有两次代码提交,其中一次是我的。于是立马检查了两次代码改动,在确认另一个同事的代码不可能内存有问题后(因为另一个同事在线只是修改了配置),我知道肯定是自己的代码有问题。确定问题后,快速回滚你的代码,然后你就可以放心调试了。调试什么是内存泄漏?简单的说,就是程序员申请的内存在使用后并没有归还给操作系统。由于作者使用的是C++语言,所以内存泄漏一般是这样的:obj*o=newobj();...//使用后obj没有被删除,肯定有一些地方没有调用内存释放申请内存后的内存。这里先介绍一下作者的代码改动。我的任务其实就是重构一段代码,把这段代码并行化。也就是说,旧的逻辑是在一个线程中串行执行的。现在我想把这个逻辑放在两个线程中并并行执行。这是最麻烦的任务之一。并行转换是比较容易出现bug的。接下来梳理了所有内存的申请和释放,包括:使用new/delete分配和释放内存使用内存池分配和释放内存,此时笔者已经开始怀疑人生了:)显然,有仍然是一个没有被注意到的问题。这是不可避免的。虽然我知道问题一定出现在修改后的代码中,但我不确定它出现在哪里。.没办法,基本上我这里只好放弃自己的人工调试,想借助一些内存检测工具帮我确定问题所在。常见的内存泄漏检测工具有valgrind、gperftools等,valgrind的优点是不需要重新编译代码就可以进行内存检测,缺点是会让程序运行起来非常慢。根据官方文档,它的运行速度会比正常程序慢20倍。-30次;gperftools需要重新编译可执行程序。这些工具需要下载、安装和测试,还涉及申请机器权限等问题。笔者认为还是比较麻烦。此外,这个问题也不是大海捞针。问题一定出在并行代码上。此时,我决定换一种思路来排查问题。由于重构后代码开始并行执行,问题大概率是多线程的问题。遇到多线程问题,首先要检查的是线程间的共享数据。多线程问题的关键——共享数据我们知道,如果线程之间没有共享数据,那么就不会有线程安全问题。我们使用的锁、信号量和条件变量实际上是用来保护共享数据的,比如锁通常是用来包含临界区的。临界区中的代码对线程共享数据进行操作;信号量的一个经典场景是生产者-消费者问题。生产者线程和消费者线程都在同一个队列上运行。这里的队列是共享数据。按照这种思路,我开始寻找在两个线程中使用的共享数据。果然在一个角落里找到了这么一段代码:auto*pb=global->mutable_obj();这是一段分配protobuf对象的代码。Protobuf是Google开发的一种类似于JSON和XML的技术,因此常用于网络通信、数据交换等场景,如RPC。不懂protobuf也没关系。事实上,上面的代码所做的是:if(global->obj==NULL){global->obj=newobj();}returnglobal->obj;值得注意的是这段代码现在会分两个线程执行,显然问题就出现在这里了。那么问题是如何产生的呢?我们假设有两个线程,线程A和线程B,当这样一段代码同时在线程AB中执行时,可能会出现以下场景:线程A获取到global->obj,检测到global->obj此时为空,所以决定为其分配内存,但不幸的是,此时发生了线程切换,线程A在为global->obj分配内存之前被挂起,如下图:if(global->obj==NULL){<------线程切换,线程A暂停执行global->obj=newobj();}returnglobal->obj;线程A挂起后线程B开始执行,这段代码也会在线程B中再次执行,所以线程B会先查看global->obj,发现是空的,于是为global->obj分配内存,之后分配内存,发生线程切换,线程B被挂起,如下所示:if(global->obj==NULL){global->obj=newobj();<--------线程切换,线程B暂停执行}returnglobal->obj;线程B挂起后,调度器决定重新运行线程A,此时线程A从中断的地方开始继续运行。还记得线程A是在哪里中断的吗?是的,在为global->obj分配内存之前就被中断了。这时候线程A继续运行,也就是再次执行global->obj=newobj()这句代码,虽然线程B已经为global->obj分配了内存。哎呀,典型的内存泄漏,线程B分配的内存已经不能正常释放了。至此,我们已经找到了问题的原因。罪魁祸首是共享数据。关键是要意识到你的线程随时会被中断,CPU会随时切换到其他线程。代码修复也很简单,再添加一个变量,两个线程不再使用共享数据,到这里问题就解决了,从发现问题到修复完成大概需要4个小时。经验教训并行重构代码是一个非常棘手的工作,很容易出现线程安全问题。解决线程安全问题首先要考虑的不是加不加锁,而是多线程是否真的需要使用共享数据,如果不需要多线程操作私有数据,就根本不会有线程安全问题.当出现线程安全问题时,第一时间集中精力排查线程使用的共享数据。内存泄漏检测工具这些检测工具虽然没有用到,但都是靠人为调试的。其实排除故障的范围比较小。如果我们不知道问题发生的代码更改,那么检测工具就非常重要。这里简单介绍下valgrind的使用,详细介绍请参考官方文档。假设有这样一个问题代码:#includevoidf(void){int*x=malloc(10*sizeof(int));x[10]=0;//问题一:越界}//问题2:内存泄漏,x没有释放intmain(){f();return0;}这段代码有两个问题:一个是越界访问数据;另一个是内存泄漏。将此程序编译为myprog。接下来使用valgrind检查程序,使用如下命令:valgrind--leak-check=yesmyprog运行完成后,valgrind会给出检测报告,程序会给出这样的关于越界的输出访问:==19182==Invalidwriteofsize4==19182==at0x804838F:f(example.c:6)==19182==by0x80483AB:main(example.c:11)==19182==Address0x1BA45050is0bytesafterablockofsize40alloc'd==19182==at0x1B8FF5CD:malloc(vg_replace:13malloc0.c)==19182==by0x8048385:f(example.c:5)==19182==by0x80483AB:main(example.c:11)第一行告诉你有Invalidwriteinthecode,即invalidwrite,给出Somethingwentwrongwheretheproblemoccurred。关于内存泄漏问题,会给出这样的输出:==19182==40bytesin1blocksaredefinitelylostinlossrecord1of1==19182==at0x1B8FF5CD:malloc(vg_replace_malloc.c:130)==19182==by0x8048385:f(example.c:5)==19182==by0x80483AB:main(example.c:11)这里第一行报告内存“definitelylost”,也就是说肯定有内存泄漏,并给出了问题的位置。事实上,除了“肯定丢失”之外,valgrind还会给出“可能丢失”的报告。这两个报告的含义如下:“definitelylost”:你的程序肯定有内存泄漏问题,修复它。“可能丢失”:您的程序看起来有内存泄漏。有可能您正在为某些特定操作使用指针,因此这不是100%有问题。总结编写正确的多线程代码从来都不是一件容易的事。线程安全问题的根源在于共享资源,所以在使用共享资源之前一定要确认我们一定要使用共享资源?