« skynet 消息队列的新设计(接上文) | 返回首页 | 嵌入式 lua 中的线程库 »

基于引用计数的对象生命期管理

最近在尝试重新写 skynet 2.0 时,把过去偶尔用到的一个对象生命期管理的手法归纳成一个固定模式。

先来看看目前的做法:旧文 对象到数字 ID 的映射

其中,对象在获取其引用传入处理函数中处理时,将对象的引用加一,处理完毕再减一。这就是常见的基于引用计数的对象生命期管理。

常规的做法(包括 C++ 的智能指针)是这样的:对象创建时,引用为 1 (或 0)。每次要传给另一个处地方处理,或保留待以后处理时,就将其引用增加;不再使用时,引用递减。当引用减为 0 (或负数)时,把对象引用的资源回收。

由于此时对象不再被任何东西引用,这个回收销毁过程就可视为安全且及时的。不支持 GC 的语言及用这些语言做出来的框架都用这个方式来管理对象。


这个手法的问题在于,对象的销毁时机不可控。尤其在并发环境下,很容易引发问题。问题很多情况是从性能角度考虑的优化造成的。

加减引用本身是个很小的开销,但所有的引用传递都去加减引用的话,再小的开销也会被累积。这就是为什么大多数支持 GC 的语言采用的是标记扫描的 GC 算法,而不是每次在对象引用传递时都加减引用。

大部分情况下,你能清楚的分辨那些情况需要做引用增减,哪些情况下是不必的。在不需要做引用增减的地方去掉智能指针直接用原始指针就是常见的优化。真正需要的地方都发生在模块边界上,模块内部则不需要做这个处理。但是在 C/C++ 中,你却很难严格界定哪些是边界。只要你不在每个地方都严格的做引用增减,错误就很难杜绝。


使用 id 来取代智能指针的意义在于,对于需要长期持有的对象引用,都用 id 从一个全局 hash 表中索引,避免了人为的错误。(相当于强制从索引到真正对象持有的转换)

id 到对象指针的转换可以无效,而每次转换都意味着对象的直接使用者强制做一个额外的检查。传递 id 是不需要做检查的,也没有增减引用的开销。这样,一个对象被多次引用的情况就只出现在对象同时出现在多个处理流程中,这在并发环境下非常常见。这也是引用计数发挥作用的领域。

而把对象放在一个集合中这种场景,就不再放智能指针了。


长话短说,这个流程是这样的:

将同类对象放在一张 hash 表中,用 id 去索引它们。

所有需要持有对象的位置都持有 id 而不是对象本身。

需要真正操作持有对象的地方,从 hash 表中用 id 索引到真正的对象指针,同时将指针加一,避免对象被销毁,使用完毕后,再将对象引用减一。

前一个步骤有可能再 id 索引对象指针时失败,这是因为对象已经被明确销毁导致的。操作者必须考虑这种情况并做出相应处理。


看,这里销毁对象的行为是明确的。设计系统的人总能明确知道,我要销毁这个对象了。 而不是,如果有人还在使用这个对象,我就不要销毁它。在销毁对象时,同时有人正在使用对象的情况不是没有,并发环境下也几乎不能避免。(无法在销毁那一刻通知所有正在操作对象的使用者,操作本身多半也是不可打断的)但这种情况通常都是短暂的,因为长期引用一个对象都一定是用 id 。

了解了现实后,“当对象的引用为零时就销毁它” 这个机制是不是有点怪怪的了?

明明是:我认为这个对象已经不需要了,应该即使销毁,但销毁不应该破坏当下正在使用它的业务流程。


这次,我使用了另一个稍微有些不同的模式。

每个对象除了在全局 hash 表中保留一个引用计数外,还附加了一个销毁标记。这个标记只在要销毁时设置一次,且不可翻转回来。

现在的流程就变成了,想销毁对象时,设置 hash 表中关联的销毁标记。之后,检查引用计数。只有当引用计数为 0 时,再启动销毁流程。

任何人想使用一个对象,都需要通过 hash 表从 id 索引到对象指针,同时增加引用计数,使用完毕后减少引用。

但,一旦销毁标记设置后,所有从 id 索引到对象指针的请求都会失败。也就是不再有人可以增加对象的引用,引用计数只会单调递减。保证对象在可遇见的时间内可被销毁。

另外,对象的创建和销毁都是低频率操作。尤其是销毁时机在资源充裕的环境下并不那么重要。所以,所有的对象创建和销毁都在同一线程中完成,看起来就是一个合理的约束了。 尤其在 actor 模式下, actor 对象的管理天生就应该这么干。

有了单线程创建销毁对象这个约束,好多实现都可以大大简化。

那个维护对象 id 到指针的全局 hash 表就可以用一个简单的读写锁来实现了。索引操作即对 hash 表的查询操作可遇见是最常见的,加读锁即可。创建及销毁对象时的增删元素才需要对 hash 表上写锁。而因为增删元素是在同一线程中完成的,写锁完全不会并发,对系统来说是非常友好的。

对于只有唯一一个写入者的情况,还存在一个小技巧:可以在增删元素前,复制一份 hash 表,在副本上慢慢做处理。只在最后一个步骤才用写锁把新副本交换过来。由于写操作不会并发,实现起来非常容易。

Comments

就樓上最後建議的做法 在multi-thread的smart pointer的設計下 就我所知 引用計數要不用鎖 要不用原子操作 總之開銷更大

樓上最後那建議的做法 會牽扯到multi-thread的smart pointer設計 就我所知 那些計數引用 要不用lock 要不用原子操作 總之開銷更大

大大, 看了你的文章之后我有两处不明白.

第一件事是你提到加减引用计数的开销问题,如果换作handle有效性查询的话,它的开销会远小于引用计数的开销吗?

第二件事是关于系统设计者想要销毁一个对象,系统设计者在设计系统的时候真的应该"想要"销毁一个对象吗?如果一个子系统使用完了对象,可以明确的是他自己已经不再需要这个对象,但它自己不再需要这个对象就可以等同这个对象再没有存在的价值吗? 比如在ObjC里很基础的一个东西[NSString stringWithFormat: @"AABB"],它返回一个带引用计数的字符串,在stringWithFormat构建这个对象的上下文中,它完全没有能力预期调用者将会如何对待这个对象,这个对象将会经历如何辗转的人生最后死在谁的手里,所以它没有能力管理这个对象的生存周期,同时,它的调用者也并不是总能有能力确切的管理这个字符串对象真正被销毁的时机.
我本人是写游戏客户端的,对于高并发的场景并没有什么切身的经验,所以以我片面的认识在这里提出这个疑问,希望前辈能给我解释一下.

另外,你提到hash表+引用计数.既然已经有引用计数了为什么还需要hash表?
可以平时就使用引用计数的智能指针,如果某个子系统想要显式销毁一个对象,则把它标记为废弃然后加到表里,由创建线程延迟进行销毁管理.这样表只管理需要销毁的对象,可以小很多,也可以少很多hash,或者还可以直接改成列表? 不知道前辈如何看待这个事情.

关于最后一个‘小技巧’, 其实算是copy on other reading。 比较感兴趣的是,如果用普通mutex 替换读写锁, 会对效率有多大的影响? 假如读操作加锁的临界区比较小, 那么用简单mutex效率可能会比读写锁高。

@mrvon

其实即使在 skynet 1.0 中,服务的创建和删除也都是委托 launcher 服务完成的 :) 只是内核提供了任意一个服务完成它的能力。

跟skynet_handle相比,主要是加了销毁标记,所有的对象创建和销毁都在同一线程中完成。

那么创建和销毁是要委托服务完成么?

听起来像 kernel 中管理各种 object(例如 dcache)的做法

感觉和weak_ptr差不多

你可以估算你的系统整个的生命期能够产生的同类对象的总数量,其实 4 字节就足够了,8 字节当然是绝对没有问题的。

如果能进一步的估计, 一个 id 被使用被销毁的足够长时间还被人不幸持有的可能性。

类似移动公司会把用户注销的电话号码冻结 2,3 年再发给新用户。在现实中对于电话号码的复用, 2,3 年似乎还不太够。但幸运的是用户有一定的纠错能力。

在正确设计的系统中,不复用 id 不是想为错误的设计买单。对象销毁该通知持有方还是应该去通知的。

它避免的是并发(分布式)系统中那种由于通讯延迟造成的复用错误。即,我已经通知你这个对象销毁了,现在我有一个新对象,复用同样的 id 。 但由于前面的通知消息到晚了,造成了混淆。

所以,id 复用周期远高于通讯延迟就够用了。

@Tim 在几乎无并发或少并发模式下,这样做是很好的。我也经常这么干。因为你明确知道没件事情是什么时候做完的。

但是在 actor 模式下时,裸指针几乎是行不通的。

因为裸指针无法通过声明我正在用它来阻止你的资源明确拥有者在我还在使用的时候销毁它。

因为任何一个工作线程都有可能被长期挂起,这是应用层不能左右的。而通常 actor 模式下,任何一段业务都可能跑在一个实际可能被挂起的线程上。

作到资源拥有者让资源存活足够久的前提就是让每小段业务逻辑片断在自己业务结束后,都告知资源拥有者已经结束。这正是引用计数解决的问题。

另外云风怎么看unique ownership?即明确定义资源拥有者(如栈变量,C++的unique_ptr,内存池等),所有使用者都通过裸指针使用资源,通过显式设计保证资源拥有者存活时长足够覆盖所有使用者?

所以id是不复用的?倘若放8个字节给id,就不考虑用完的情况了?

Post a comment

非这个主题相关的留言请到:留言本