👀

WeakRef と console.log

2021/07/07に公開

ES2021 に入った WeakRef を実験したらハマったのでメモ

tl;dr

  • devtool を開いた状態で console.log すること自体が参照になる
  • WeakRef で取得した参照を console.log すると、それも参照なので、本来開放されるはずでもリソースの開放が行われなくなる
  • 循環参照オブジェクトでも、内部での循環参照に閉じてる限りちゃんと捨てられる(少なくともv8は)

確かめたこと

循環参照のオブジェクトの GC がなされず困っていたので、確認のために色々と実験していた。

まず、循環参照オブジェクトを作る。この Node は再帰的に辿れるように、 parent と children で相互に参照を持つ。

Chrome のメモリプロファイラをみたかったので、vite で雑に作った環境で、次のコードを実行する。

const app = document.querySelector<HTMLDivElement>("#app")!;

class Node {
  public children: Node[] = [];
  constructor(public parent: Node | null, public value: string) {}
}

function createCircularObject(parent: Node | null, depth: number): Node {
  const n: Node = new Node(parent, Math.random().toString(32));
  if (depth > 0) {
    [...Array(30).keys()].map(() => {
      const newNode = createCircularObject(n, depth - 1);
      n.children.push(newNode);
    });
  }
  return n;
}

const button = document.createElement("button");
let cnt = 0;
button.textContent = "start";
button.addEventListener("click", () => {
  const n = createCircularObject(null, 3);
  console.log("round:", cnt, n);
});
app.appendChild(button);

export {};

ボタンを押すと循環参照オブジェクトを生成する。 Chrome DevTools で Memory Tab を開いて、ここで何度かボタンを押してみる。

次の画像は、リロード直後に一回、ボタンを2回押して一回。その後、時間を少し置いて(GCが発動するのを待って)もう一回メモリプロファイラを起動したもの。

一度実行するたびに、約 1.75M のメモリが漏れている。

ここで一つの疑問があった。 v8 の世代別GCの場合、 内部的に循環参照が起きていたとしても、それが一つのオブジェクトの内部に閉じていいてルート要素が外から参照されないならば、マークされずメモリが回収されるはず、という仮説があったが、どうもそのようには動いてない。

ここでふと思いついて console.log を消してみたところ、メモリが開放されるようになった。

つまり、 Dev Tools を開いている場合、 console.log で表示したこと自体が参照になる。よく考えてみたら当然の話で、DevTools はインスペクタで再帰的にオブジェクトを展開できる。 window.parent.parent.parent みたいにアクセスできるのはそのせい。

WeakRef を使う

今回の本題だが、ES2021 で WeakRef が入った。IE 以外はだいたい使える。 https://caniuse.com/?search=WeakRef

WeakRef は弱参照で、 const ref = new WeakRef(obj); で参照を作り、 ref.deref() で参照を返す。もし GC によって参照先が破棄されていれば undefined が返却される。つまり、間接的にGCを観測できる。 WeakRef 自体の参照先はマークアンドスイープのマークとならない。

クロージャがある JavaScript では言語仕様的に意図しない参照を防ぐことは難しく、console.log 以外にも、ついうっかり参照を残してしまうことがある。とくにフロントエンドはイベントリスナが正しく開放されることを確認するのが難しい。(TS に Rust のライフタイム入れるみたいな激しい変更を入れるなら可能だが…)

なので、今回は WeakRef で弱参照で回避できるかを検証した。

const app = document.querySelector<HTMLDivElement>("#app")!;

class MyNode {
  public children: WeakRef<MyNode>[] = [];
  constructor(public parent: WeakRef<MyNode> | null, public value: string) {}
}

function createCircularObject(
  parent: WeakRef<MyNode> | null,
  depth: number
): MyNode {
  const n: MyNode = new MyNode(parent, Math.random().toString(32));
  if (depth > 0) {
    [...Array(30).keys()].map(() => {
      const newNode = createCircularObject(new WeakRef(n), depth - 1);
      const newRef = new WeakRef(newNode);
      n.children.push(newRef);
    });
  }
  return n;
}

const button = document.createElement("button");
let cnt = 0;
button.textContent = "start";
button.addEventListener("click", () => {
  const n = createCircularObject(null, 3);
  console.log("round:", cnt++, n);
});
app.appendChild(button);

console.log によって掴んでいるのでルート要素の MyNode は一つずつ漏れる。しかし、それ以外はリリースされている。

追記

で、上のコードはよく見ると根本的に間違っていて、children への参照が weakRef のみによる参照なので、次回の GC でルート要素一つ以外は全部開放されてしまう。GC以外をこれがメモリプロファイラを見た結果の意味。

これをオブジェクト内の循環参照を防ぎたい、という本来の意図通り正しく書きたければ、 parent の参照を WeakRef で作るべきだった。

つまりこういうこと

const app = document.querySelector<HTMLDivElement>("#app")!;

class MyNode {
  public children: MyNode[] = [];
  constructor(public parent: WeakRef<MyNode> | null, public value: string) {}
}

function createCircularObject(
  parent: WeakRef<MyNode> | null,
  depth: number
): MyNode {
  const n: MyNode = new MyNode(parent, Math.random().toString(32));
  if (depth > 0) {
    [...Array(30).keys()].map(() => {
      const newNode = createCircularObject(new WeakRef(n), depth - 1);
      n.children.push(newNode);
    });
  }
  return n;
}

const button = document.createElement("button");
let cnt = 0;
button.textContent = "start";
button.addEventListener("click", () => {
  const n = createCircularObject(null, 3);
  console.log("round:", cnt++, n);
});
app.appendChild(button);

木構造の付け替えで子供だけが生き残って親がいないときにメモリをもれないようにしたい、といった場合にこれで参照が正しく切れるようになる。

結局、これは console.log をやめないとメモリは漏れていく。

結論

console.log によるプリントデバッグは参照の一つになりうるので、 WeakRef と一緒に使うときは対象の参照ではなく、なんらかの id のような間接的な値を見る必要がある。いわゆる、「プリントデバッグしたらランタイム挙動が変わる」類のものが発生する。

もしかしたら Devtool を開かない状態の console.log はメモリが漏れないかもしれないが、 Devtool を開かないと Memory Profiler が起動できないので、定かではない。

Discussion