最近在尝试重新写 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 表,在副本上慢慢做处理。只在最后一个步骤才用写锁把新副本交换过来。由于写操作不会并发,实现起来非常容易。