Skip to content

Latest commit

 

History

History
131 lines (73 loc) · 7.33 KB

README-ZHCN.md

File metadata and controls

131 lines (73 loc) · 7.33 KB

[2020-07-30-03:53:58] 作者 杨逸林

RCGC 算法

这几天因为项目的需要,又拿起了COM(Common Object Model),因为不能用ATL或者MFC, 只能自己亲手重写IUnknown。这就涉及到AddRef以及Release的具体实现方法,以及那个 m_RefCount的初始值等细节上的问题。

一番折腾之后,还是正确的实现了需要的COM类和对象。但是,既然是C++,C++显然有更好的 处理方式:也就是shared_ptr,这样一个模板类。

似乎不管啥指针,套在shared_ptr里面,C++都能把它自动管理好。然而,它也有问题, 也就是众所周知的循环引用问题。如果A类的对象里面通过shared_ptr引用了B类的对象,而B类 的这个对象又在其中引用了A类的那个对象,那么shared_ptr使用的引用计数机制,就不灵了。

为啥呢?这就涉及到了shared_ptr的实现问题。总得来说,shared_ptr,把每个指针都对应一个数量 记录下来,也就是引用计数。正常的情况下,引用计数总是和释放的次数相应。就是说,你引用了 一个对象多少次,你就释放多少次就行了。可是,如果涉及到循环引用,就会出问题:当引用到包含 了自己的对象的时候,引用计数就会比释放的次数多1次,两个对象互相引用,总共多2次。

这两次多余的引用次数,就导致在释放对象的时候不能计数不能清零,最终也不会达到释放对象占用 内存的条件。而如果用COM那种方式,恐怕情况会更糟:delete this 对于循环引用来说,很可能造成 对象析构函数之间的间接递归调用,最终导致栈溢出。

标准C++的处理方式,是引入weak_ptr,也是一种智能指针。只是这种指针,你要用它的时候,得首先问问, 它指向的对象是否真的存在。这当然也是有效的方法,只是说,啥时候用weak_ptr,啥时候用shared_ptr,自己得好好设计一下。

那么,有没有办法,就只用shared_ptr,也能避免循环引用的问题呢?

其实,循环引用,并不是真正的绊脚石。如果一个智能指针,确实能够记得住它底层指针到底被用了多少次, 那么真的就是引用多少次,释放多少次即可。但是实际设计上,我们总是倾向在计数为0的时候,直接释放底层指针 所对应的对象(调用析构函数)以及它所占据的内存(对其调用free)。而这两个步骤,正好就是delete依次 完成的。

可是delete先是调用了析构,然后又释放了内存,而内存中还有智能指针记录的数据。也就是说,在delete释放 内存的这一步,智能指针里面的数据也丢了,这就让它所占据的那个计数,没能被减去。或者如果一定要它被减去, 则被释放的内存,还得保持原来的数据。

在Debug模式中,原来的数据肯定是要清除的。虽然在Release模式中不清除,但这是危险的做法(一定不要依赖)。 所以,若要保证所有的智能指针都正确的工作,把每一个计数都正确的减去,我们就不能让这个指针所占据的内存 轻易的被释放:我们可以考虑把delete的两个阶段拆开来处理。

第一个阶段,是对要被delete的对象,调用析构函数。而析构函数自动调用内部的智能指针的析构函数。我们在 智能指针的析构函数被调用的时候,主动调用智能指针的底层对象的析构函数,却避免释放内存(不调用delete, 也不调用free)。这样的话,底层指针指向的对象中的智能指针的析构函数又会被调用,而结果则是其底层指针的 析构函数被调用,整个过程中没有任何内存被释放掉,系统自动调用每个底层对象的析构函数以及其含有的智能指针 的析构函数,在这些调用之中,只减少引用计数,不释放任何内存。如果发现任何底层对象的引用计数已经为0, 则把它放在一个特殊的地方,等待释放。

第二个阶段,对那些已经挑出来的对象,调用free函数,释放内存。显然这些挑出来的底层指针指向的对象,已经调用过 析构函数,能释放的已经释放,该调用的已经调用。现在它们就只是指向了有内容,却无需动作的内存空间。这时候, 直接用free释放这个空间即可。

把这两个阶段分开,就可以精确的对每个使用智能指针的对象进行引用计数。可能存在出现负数的情况,但是,我们已经 设置了初始值为1,那么,至多减少1次,就得到0的结果。所以只要检测到0,就可以安全的把对象放在释放列表中了。

这个做法,就好像是其它语言中的GC(垃圾回收机制),不是直接释放内存,而是等到一个特殊的时刻一起处理。 然而,不同于GC,这个做法的好处在于,这个特殊的时刻,可以就是任何对象释放的时刻。也就是说,一个被释放的 对象造成的连带释放,可以在一次释放过程中完全被检测出来。这就相当于立即释放:立即精确的释放所有的相关 对象,而这正是引用计数方法的特点。

引用计数方法可以立即释放对象,但对于循环引用,则或者不能释放,或者造成堆栈溢出。现在,我们把计数减少的 过程和最终释放内存的过程分成两个阶段,那么不能释放或者堆栈溢出都可以被避免。

事实上,既然已经可以精确的得出需要释放的所有底层指针,我们还可以延迟释放。也就是说,先不急着释放, 等需要的时候再释放。而这就和GC非常像了。此外,还有一个更好的特性:既然可以延迟释放,既然那些底层指针 所指向的内存绝对不会再被使用,那么,我们就可以单独用一个线程来作为释放线程。

可以手动启动释放内存的线程,它将会把当前所有需要释放的底层指针都free掉。也可以使用某种定时设计或者对 当前内存使用总量进行监控。在需要的时候,用自动启动的线程来释放内存,或者用监控线程本身来释放内存。

而这些在另一个线程中进行的释放活动,(几乎)完全不会影响其它线程中请求内存的操作,这是因为,可以把 待释放的内存列表单独拷贝出来,而不用共享列表,所以即便上锁,也只是快速拷贝阶段才上锁。这就使得系统 性能可以得到最大的提升。

基本上这就是所谓的RCGC算法。用在C++上显然可以,以相同的原理移植到C语言或者GO语言当然也行。 事实上,也可以把它改装到C#等纯GC系统中,使用引用计数加上独立线程释放内存的方式显然要比 STOP-THE-WORLD高效得多了。

P.S:

在类中,有些成员变量(字段)使用rcgc_ptr管理,有些只是在析构函数中释放的情况下,需要如何处理? 不难想到,由于类对象的析构函数(不是特定智能指针的析构函数)可能被反复调用,那些成员变量可能已经被 delete释放了,但是析构函数还是会再调用一次。所以为了保证安全,应当使用如下方法:

~A(){

if(this->_ptr_B!=nullptr){

	delete this->_ptr_B;

	this->_ptr_B=nullptr;

}

}

但若可能,最好都使用rcgc_ptr来管理。

恳请批评指正:

yyl_20050115@hotmail.com

祝好,

杨逸林