Weak references とファイナライザー

公開日 · 更新日 · タグ ECMAScript ES2021

一般的に、JavaScript におけるオブジェクトへの参照は強く保持されます。つまり、オブジェクトへの参照を持っている限り、そのオブジェクトはガベージコレクションされません。

const ref = { x: 42, y: 51 };
// As long as you have access to `ref` (or any other reference to the
// same object), the object won’t be garbage-collected.

現在、WeakMapWeakSet は、JavaScript でオブジェクトを弱く参照する唯一の方法です。オブジェクトを WeakMap または WeakSet のキーとして追加しても、そのオブジェクトがガベージコレクションされるのを妨げません。

const wm = new WeakMap();
{
const ref = {};
const metaData = 'foo';
wm.set(ref, metaData);
wm.get(ref);
// → metaData
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `wm` to
// which we still have access.

const ws = new WeakSet();
{
const ref = {};
ws.add(ref);
ws.has(ref);
// → true
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `ws` to
// which we still have access.

注: WeakMap.prototype.set(ref, metaData) は、オブジェクト ref に値 metaData を持つプロパティを追加すると考えることができます。オブジェクトへの参照を持っている限り、メタデータを取得できます。オブジェクトへの参照がなくなると、そのオブジェクトは、追加された WeakMap への参照が残っていても、ガベージコレクションの対象になります。同様に、WeakSet はすべての値がブール値である WeakMap の特殊なケースと考えることができます。

JavaScript の WeakMap は実際には弱いわけではありません。キーが有効である限り、その内容は強く参照されます。WeakMap がその内容を弱く参照するのは、キーがガベージコレクションされた後のみです。この種の関連性をより正確に表す名前は、エフェメロンです。

WeakRef は、オブジェクトの生存期間を垣間見ることができる、より高度な API です。一緒に例を見ていきましょう。

たとえば、サーバーとの通信に Web ソケットを使用するチャット Web アプリケーションに取り組んでいるとします。パフォーマンス診断の目的で、WebSocket からのイベントのセットを保持して、レイテンシーの単純移動平均を計算する MovingAvg クラスがあると想像してください。

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

compute(n) {
// Compute the simple moving average for the last n events.
// …
}
}

これは、レイテンシーの単純移動平均の監視を開始および停止するタイミングを制御できる MovingAvgComponent クラスで使用されます。

class MovingAvgComponent {
constructor(socket) {
this.socket = socket;
}

start() {
this.movingAvg = new MovingAvg(this.socket);
}

stop() {
// Allow the garbage collector to reclaim memory.
this.movingAvg = null;
}

render() {
// Do rendering.
// …
}
}

MovingAvg インスタンス内にすべてのサーバーメッセージを保持すると大量のメモリを使用するため、監視が停止したときに this.movingAvg を null にして、ガベージコレクターがメモリを再利用できるように注意します。

しかし、DevTools のメモリパネルで確認したところ、メモリがまったく再利用されていないことがわかりました!経験豊富な Web 開発者ならすでにバグを見つけているかもしれません。イベントリスナーは強い参照であるため、明示的に削除する必要があります。

到達可能性図でこれを明示的にしてみましょう。start() を呼び出した後、オブジェクトグラフは次のようになります。ここで、実線矢印は強い参照を意味します。MovingAvgComponent インスタンスから実線矢印で到達可能なものはすべて、ガベージコレクションの対象にはなりません。

stop() を呼び出した後、MovingAvgComponent インスタンスから MovingAvg インスタンスへの強い参照を削除しましたが、ソケットのリスナーからは削除していません。

したがって、MovingAvg インスタンスのリスナーは、this を参照することにより、イベントリスナーが削除されない限り、インスタンス全体を存続させます。

これまで、解決策は dispose メソッドを使用して手動でイベントリスナーを登録解除することでした。

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

dispose() {
this.socket.removeEventListener('message', this.listener);
}

// …
}

このアプローチの欠点は、手動でのメモリ管理であることです。MovingAvgComponent および MovingAvg クラスの他のすべてのユーザーは、dispose を呼び出すことを覚えていないと、メモリリークが発生します。さらに悪いことに、手動のメモリ管理は連鎖的です。MovingAvgComponent のユーザーは、stop を呼び出すことを覚えていないと、メモリリークが発生します。したがって、このように続きます。アプリケーションの動作は、この診断クラスのイベントリスナーに依存せず、リスナーは計算量ではなくメモリ使用量の点でコストがかかります。本当に望ましいのは、リスナーの有効期間が MovingAvg インスタンスに論理的に結び付けられ、MovingAvg がガベージコレクターによってメモリが自動的に再利用される他の JavaScript オブジェクトのように使用できるようにすることです。

WeakRef を使用すると、実際のイベントリスナーへの弱い参照を作成し、その WeakRef を外部のイベントリスナーでラップすることで、ジレンマを解決できます。このようにして、ガベージコレクターは、実際のイベントリスナーと、MovingAvg インスタンスやその events 配列のように、保持されているメモリをクリーンアップできます。

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener);
const wrapper = (ev) => { weakRef.deref()?.(ev); };
socket.addEventListener('message', wrapper);
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); };
addWeakListener(socket, this.listener);
}
}

注: 関数への WeakRef は注意して扱う必要があります。JavaScript の関数はクロージャであり、関数内で参照される自由変数の値を含む外部環境を強く参照します。これらの外部環境には、他のクロージャも参照する変数が含まれている場合があります。つまり、クロージャを扱う場合、そのメモリは、微妙な方法で他のクロージャによって強く参照されることがよくあります。これが、addWeakListener が別の関数であり、wrapperMovingAvg コンストラクターに対してローカルではない理由です。V8 では、wrapperMovingAvg コンストラクターに対してローカルであり、WeakRef でラップされているリスナーとレキシカルスコープを共有している場合、MovingAvg インスタンスとそのすべてのプロパティは、ラッパーリスナーから共有環境経由で到達可能になり、インスタンスが収集不可能になります。コードを作成する際は、このことを念頭に置いてください。

最初にイベントリスナーを作成し、this.listener に割り当てて、MovingAvg インスタンスによって強く参照されるようにします。言い換えれば、MovingAvg インスタンスが有効である限り、イベントリスナーも有効です。

次に、addWeakListener で、ターゲットが実際のイベントリスナーである WeakRef を作成します。wrapper 内で、それを deref します。WeakRef は、ターゲットに他の強い参照がない場合、ターゲットのガベージコレクションを防がないため、手動で逆参照してターゲットを取得する必要があります。ターゲットがその間にガベージコレクションされている場合、derefundefined を返します。それ以外の場合は、元のターゲットが返され、それが オプションチェイニングを使用して呼び出す listener 関数です。

イベントリスナーは WeakRef でラップされているため、それへの唯一の強い参照は MovingAvg インスタンスの listener プロパティです。つまり、イベントリスナーの有効期間を MovingAvg インスタンスの有効期間に正常に結び付けました。

到達可能性図に戻ると、WeakRef 実装で start() を呼び出した後のオブジェクトグラフは次のようになります。ここで、点線矢印は弱い参照を意味します。

stop() を呼び出した後、リスナーへの唯一の強い参照を削除しました

最終的に、ガベージコレクションが発生した後、MovingAvg インスタンスとリスナーが収集されます

しかし、ここにはまだ問題があります。listenerWeakRef でラップすることで間接参照のレベルを追加しましたが、addWeakListener のラッパーは、listener が元々リークしていたのと同じ理由で依然としてリークしています。確かに、これは、MovingAvg インスタンス全体ではなくラッパーのみがリークしているため、より小さなリークですが、それでもリークです。これに対する解決策は、WeakRef のコンパニオン機能である FinalizationRegistry です。新しい FinalizationRegistry API を使用すると、ガベージコレクターが登録済みオブジェクトを削除したときに実行するコールバックを登録できます。このようなコールバックはファイナライザーと呼ばれます。

注: ファイナライザーコールバックは、イベントリスナーをガベージコレクションした直後に実行されるわけではないため、重要なロジックやメトリクスには使用しないでください。ガベージコレクションとファイナライザーコールバックのタイミングは指定されていません。実際、ガベージコレクションをまったく行わないエンジンも完全に準拠しています。ただし、エンジンはガベージコレクションを実行し、ファイナライザーコールバックは、環境が破棄されない限り(タブが閉じたり、ワーカーが終了したりするなど)、後で呼び出されると想定しても安全です。コードを作成する際は、この不確実性を念頭に置いてください。

FinalizationRegistry にコールバックを登録して、内部イベントリスナーがガベージコレクションされたときにソケットから wrapper を削除できます。最終的な実装は次のようになります

const gListenersRegistry = new FinalizationRegistry(({ socket, wrapper }) => {
socket.removeEventListener('message', wrapper); // 6
});

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener); // 2
const wrapper = (ev) => { weakRef.deref()?.(ev); }; // 3
gListenersRegistry.register(listener, { socket, wrapper }); // 4
socket.addEventListener('message', wrapper); // 5
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); }; // 1
addWeakListener(socket, this.listener);
}
}

注: gListenersRegistry は、ファイナライザーが実行されることを保証するためのグローバル変数です。FinalizationRegistry は、登録されているオブジェクトによって存続させられません。レジストリ自体がガベージコレクションされた場合、そのファイナライザーは実行されない可能性があります。

イベントリスナーを作成し、MovingAvg インスタンスによって強く参照されるように this.listener に割り当てます (1)。次に、内部で動作するイベントリスナーを WeakRef でラップして、ガベージコレクションできるようにし、this 経由で MovingAvg インスタンスへの参照をリークしないようにします (2)。WeakRefderef して、まだ有効かどうかを確認するラッパーを作成し、有効な場合はそれを呼び出します (3)。内部リスナーを FinalizationRegistry に登録し、登録に保持値 { socket, wrapper } を渡します (4)。次に、返されたラッパーを socket のイベントリスナーとして追加します (5)。MovingAvg インスタンスと内部リスナーがガベージコレクションされた後、しばらくして、ファイナライザーが実行され、保持値が渡されます。ファイナライザー内部で、ラッパーも削除し、MovingAvg インスタンスの使用に関連付けられたすべてのメモリをガベージコレクションできるようにします (6)。

これにより、MovingAvgComponent の元の実装はメモリをリークせず、手動による破棄も必要ありません。

やりすぎないでください #

これらの新しい機能について聞くと、WeakRef をすべてに適用したくなるかもしれません。ただし、それはおそらく良い考えではありません。WeakRef とファイナライザーのユースケースとして明示的に適していないものがあります。

一般に、ガベージコレクターが WeakRef をクリーンアップしたり、予測可能なタイミングでファイナライザーを呼び出したりすることに依存するコードの記述は避けてください。 - それを行うことはできません! さらに、オブジェクトがガベージコレクション可能かどうかは、クロージャの表現など、JavaScript エンジン間で、さらには同じエンジンの異なるバージョン間でも微妙で異なる可能性のある実装の詳細に依存する場合があります。具体的には、ファイナライザーコールバック

  • ガベージコレクションの直後に発生しない可能性があります。
  • 実際のガベージコレクションと同じ順序で発生しない可能性があります。
  • たとえば、ブラウザーウィンドウが閉じられた場合など、まったく発生しない可能性があります。

したがって、重要なロジックをファイナライザーのコードパスに配置しないでください。それらはガベージコレクションに応答してクリーンアップを実行するのに役立ちますが、メモリ使用量に関する意味のあるメトリクスを記録するために確実に使用することはできません。そのユースケースについては、performance.measureUserAgentSpecificMemory を参照してください。

WeakRef とファイナライザーは、メモリを節約するのに役立ち、プログレッシブエンハンスメントの手段として控えめに使用するのが最適です。これらはパワーユーザー向けの機能であるため、ほとんどの使用はフレームワークまたはライブラリ内で行われると予想されます。

WeakRef のサポート #