算法与数据结构 / 持久化与不可变结构 · 从 path copying 到 RRB-tree / transient:临时放弃持久性的可变窗口 待审核 6 / 7
transient · ownership · withMutations

transient:临时放弃持久性的可变窗口

不可变结构的写操作总要新建一条路径。这在「改一处、留一档」的场景里就是想要的效果,但在「从零建一个 10 万元素的 vector」里全是浪费:中间那 99999 个版本没有任何人看,造出来就为了下一步把它扔掉。

实测这笔浪费的规模:10 万次 push 逐次持久化共新建 11441 个节点,最终活着的只有 3227 个,72% 当场成垃圾。批量 set 更极端——10 万次单点修改新建 399872 个节点,活着的仍是 3227 个。

Clojure 给出的解法叫 transient,Immutable.js 里同一个东西叫 withMutations

1 · 所有权标记

思路是给每个节点记「它属于哪一次批量构建」。

定义 1.1(transient) 一个带 owner 标记的可变视图。asTransient(v) 生成一个全局唯一的 id 并复制根节点、给它打上这个 id。此后的写操作下降到某节点时:若该节点的 owner 等于当前 id,说明它是本次构建期间造出来的、外界没有任何引用,可直接原地改;否则先复制一份、打上 id,再改副本。persistentVec(t) 把 id 置空,结构随即冻回持久化。

判据「owner 等于当前 id」等价于「这个节点自本次构建开始后被造出来,因而不可能被本次构建之外的任何版本引用」。这一条成立时原地改是安全的:没有人能看见它变。第一次下降会沿路把旧节点全部复制一遍(它们属于构建之前的版本),从第二次起沿途就都是自己的了。

id 全局递增而不复用,是为了让「上一次构建留下的标记」不会被下一次误判为自己的。引擎里的实现是一个模块级计数器,冻结时只置空句柄上的 id、不去遍历清理节点上的标记——节点上的旧 id 此后再也匹配不上任何活着的句柄。

2 · 批量构建的账

省下的正是「每次复制一整条路径」。第一次写复制 DD 个节点(DD 为树深),此后每次写 0 个,直到需要新挂叶子或长出新层。

图 2-1 · 同一串 push 用两种写法各新建多少个节点,以及其中有多少当场成为垃圾。可拖动元素数观察倍数随规模变化。

几组实测(元素值与顺序完全相同,产物逐项相等、树形也相同):

操作 逐次持久化 transient 倍数
1024 次 push 63 个节点 33 个节点 1.9
100000 次 push 11441 个节点 3228 个节点 3.54
100000 次 set 399872 个节点 3227 个节点 123.9

push 的倍数远小于 set,因为 tail 已经吸收掉 96.88% 的 push,剩下能省的只有挂叶子那部分。set 没有 tail 这层缓冲,每一次都要走完整条路径,所以 transient 在 set 上的收益接近「树深倍」乘以「操作次数」。

3 · 冻结与句柄作废

persistentVec 之后原句柄必须失效。这不是洁癖:冻结版本与句柄共用同一批节点,句柄若还能写,它改的就是冻结版本正在用的那些节点,已经交出去的不可变值会在别人手里变形。Clojure 的做法是把根上的 AtomicReference<Thread> 置空,此后任何 ensureEditableIllegalAccessError;引擎里同样把句柄的 owner 置空,写操作抛错。

图 3-1 · transient 的三段生命周期与冻结后的写保护。可在冻结之后继续点写操作,观察它抛错而冻结版本与原版本都不受影响。

警示 · transient 的安全性不由类型系统保证,只由「不要保留旧句柄」这条纪律保证。persistentVec(t) 返回新值而不是原地把 t 变成持久化的,用意是让「继续用 t」这个写法在运行期立刻炸掉,而不是悄悄产生别名。Clojure 文档里同一条写作「transients 不是值,不要把它当值传递」。

4 · 节点数与时间的背离

节点数省下 123.9 倍,时间却只省 1.56 倍——这是本页最该记住的一组数。

10 万次 set 的实测:逐次持久化 27.8 ms,transient 17.8 ms。分配确实少了两个数量级,但每次 set 仍然要从根走 4 层找到目标叶子,这段指针追逐两种写法一模一样。省掉的只是「走到之后再复制一遍」。JS 引擎的分代 GC 对这种朝生夕死的小对象本来就很便宜,于是分配量的下降没有按比例反映到墙钟时间上。

push 那一侧倒是接近同比:4.8 ms 降到 1.5 ms,3.23 倍,与节点数的 3.54 倍相当。差别在于 push 的树遍历本来就短(只走右脊,且大部分时候连树都不碰),复制在总开销里占比更高。

所以 transient 的收益取决于「复制在总开销里占多大比重」,而不是「省了多少次分配」。批量 pushconcat、从数组建表这类操作里它值得用;随机 set 密集的场景收益有限,真要提速得先想办法减少遍历次数。

5 · 参考文献

  1. Hickey, R. (2009). Transient data structures. clojure.org/reference/transients.
  2. Bagwell, P., & Rompf, T. (2011). RRB-Trees: Efficient immutable vectors. Technical Report, EPFL. §6 讨论批量构建。
  3. L'orange, J. N. (2014). Understanding Clojure's transients. hypirion.com
  4. Immutable.js 的 withMutations / asMutable / asImmutable