内存异常排查
今天开电话会议,帮助合作方排查 C++ 开发的程序崩溃的原因。
现象是这样的:
有一个 C++ 对象,其中有一个 vector ,里面存放了大量对象指针。这个 C++ 对象在构造完毕后,没有任何改写操作,全部是只读的。
在某个退出环境中,C++ 对象被析构,析构函数需要调用 vector 中所有对象指针的删除方法。这时,这个长度大约为 100 多的 vector 第 95 个指针是错的。比它应该的指针位置偏移了一到三字节(多次运行现象不一,出错几率也不大)。
整个数组只有这一个指针错误。因为这个错误,引发了程序崩溃。
从电话中的描述,我推断:
这不太可能是由错误的调用此 C++ 对象的方法改写 vector 的内容导致的,因为这个对象全部是读方法,并加了 const 修饰。且,这个数组保存的是对象地址。一个由正常的内存分配器分配出来的地址都是对齐的,但此处错误的被改写为奇数。
这个错误也不太可能是由内存越界写造成的,因为在这一长片内存中,只有一个指针异常。
最大的可能是:某个对象被删除,但其指针还在使用造成的,所谓悬空指针问题。
那么,导致是这个出问题的对象指针悬空,还是另一个对象指针悬空影响的呢?
最大的可能是,这个出问题对象所占据的内存空间,曾经被别的对象使用过。但是前任使用者释放了内存,却在某处保留了指针。然后出问题的对象复用了这块地址。
做这个判断是因为,如果出问题的对象本身是悬空指针,那么后来者占用了它的内存的话,应该成片的改写。
从现象可以推断,内存异常一定是对原有数据进行递减造成的,而不是简单的覆盖了一个新的值。
从退出时出错可以判断,这个 C++ 对象在有效生命期间很有可能一直是正常的,否则指针错误很可能很早就暴露出来了。
那么,我认为最大的可能性就是:有另一个 C++ 采用了引用计数的机制。这个对象曾经引用到 0 而被正确的析构并释放掉了。但某种原因在另一个地方还保留了针对它的 raw 指针。
这个被释放掉的对象的内存被后来的出问题的这个 C++ 对象复用,一系列的退出析构操作导致了一系列的对象析构函数的调用。那个悬空的 raw 指针被调用了。这个指针指向的对象没有虚表,所以它的析构函数可以正确的执行。引用计数这个量存在的位置恰巧在后来出问题的 C++ 对象的中间。
减引用的函数把这个位置的值,也就是另一个对象的指针作为数值减了 1 ,发现不到 0 就跳过了。接下来的析构操作到这个位置时,访问了不正确的指针。这里期盼的指针引用的对象有虚表,偏移的差别导致跳转到虚析构函数出错。
从电话里可以获得的信息有限,我的推断只能到这里了。
为了进一步的盘查错误,我建议:把所有的对引用计数的操作,包括标准库中的智能指针的代码,都加上 assert 判断。断言所有的引用计数的值都应该在 0 到 10000 之间。
我并不是说智能指针的实现会有问题,尤其是采用标准库的实现一定没有问题。
但是,当一个对象本身被释放后,它的(悬空)指针还可以被调用析构函数却是一个隐患。悬空指针调用的对象中如果有一个智能指针的话,那么这个智能指针的析构函数依旧是会做递减操作的。
顺便吐槽 C++ 。
我现在看到的这个 C++ 项目,如果用一个标准的 windows 下的 malloc ,也就是一个性能比较低下的内存管理器,性能简直不能接受。你必须换一个非常优秀的内存管理器才能正常工作。这样的依赖内存分配器的性能,在我见过的 C 开发的项目中几乎不可能存在。
这是因为 C++ 的项目多半层次混乱。我说的混乱,不一定指开发逻辑层次上的混乱,而是假借高性能之名,看起来在源代码层次把软件的层次分清楚了,但是在二进制层面却是混杂在一起的。
一个小小的内存管理模块,就穿插于最底层到最上层。
一个层次分明的系统,在物理上就应该是相互隔离的,这种隔离,仅仅存在于人阅读的源代码层是绝对不够的。这就好比 OS 管理下的应用进程,它绝对不依赖应用进程的程序的工作正常,不依赖应用进程准确的申请和释放资源。而是当应用进程结束后,干净的回收它申请过的所有东西。
小到同一进程下的软件,也理所当然要按这个思路进行才对。C++ 不强调这一点,反而鼓励程序员生产出庞大的软件,并美其名曰:信任程序员。但却把用 C++ 的程序员引向一条邪路。信任程序员和放任程序员是两码事。