[検証]arrow functionをbindに変えたらメモリリークが治る、はどういうことか
3行まとめ
- arrow functionの使い方次第でGCが効かなくなることがある
- V8/JSCのclosure context sharingが原因で、同スコープの別クロージャが変数を参照していると、全クロージャがその変数を保持してしまう
- 通常のコードなら問題ない。大きなデータ+長命クロージャの組み合わせに注意
下記のPostが流れてきた
そんなこともあるんだなーと流し読んでいたが、ちょっと腑に落ちないので検証してみた
これはつまり、arrow functionがあるとその親scopeのデータを保持しないといけないからmemoryが解放されない、ということのようだ
Postのスクショにのっているのは
if (signal) signal.addEventListener('abort', () => controller.abort());
const timeout = setTimeout(() => controller.abort(), ms);
const abort = controller.abort.bind(controller);
if (signal) signal.addEventListener('abort', abort, {once:true});
const timeout = setTimeout(abort, ms);
{once:true}は関係なく、bindの有無がmemory leak対策になったというコメントが付いている
さて、これの文脈を予想すると、おそらくこの上にこのスコープでしか参照されないデータがあり、それを() => controller.abort() ではもちろん参照していないから、returnしたらそのデータをGCに回収してほしいのに、されなかった、ということだと理解した。
const someFunc = ()=>{
// 何らかのscope
const someInstance = new SomeClass();
const largeData = createLargePayload(); //このスコープでしか参照されないデータ
const ms = 1000;
addListener(() => someInstance.someFunction());
const timeout = setTimeout(() => someInstance.someFunction(), ms);
return;
// returnしてfunctionからぬけたらもうbigDataが使われることがないので
// GCに回収されてほしいが、回収されずにのこっている
}
これを見た感想
「そんなことある???」
もう使われてないのはコード上明確だし、nodeとかbunって賢いと思うので、ちゃんとGC回収してくれるのでは?もしくは回収できないなら何らかのエッジケースがありそうで、それをしりたい。
ということでサンプルコードを書いてみた
実験の条件
プログラムから任意のタイミングでGCを動かすために、--expose-gc付きでNode.jsを起動している。logMemoryはglobal.gc()で強制GCした後にheap使用量を出力する関数。
const logMemory = (tag) => {
global.gc?.(); // 強制GCしてから計測する
const usage = process.memoryUsage();
console.log(`${tag}: heap=${(usage.heapUsed / 1024 / 1024).toFixed(2)}MB`);
};
1回だけだとメモリの差が小さすぎて観測しにくいので、同じパターンを2000回ループさせてbefore/afterを比較する方式にした。
検証1: 素直に書いてみる → リークしない
logMemory("before");
for (let i = 0; i < ITERATIONS; i++) {
const someInstance = new SomeClass();
const largeData = createLargePayload(); // ~400KB
addListener(() => someInstance.someFunction());
setTimeout(() => someInstance.someFunction(), MS);
}
logMemory("after");
冒頭のコードとほぼ同じ構造。largeDataはスコープにあるけど、arrow functionはsomeInstanceしか使っていない。関数を抜ければlargeDataはGCが回収してくれるはず。
もしリークしていたら、400KB × 2000回 = 約800MBのheap増加になるはず。
before: heap=3.38MB
after: heap=4.07MB
+0.69MB。リークしていない。V8はちゃんとlargeDataを回収してくれている。じゃあ元のPostで起きていた現象は何だったのか。
Claudeに聞いてみる
リークしない。ということは、元のPostの状況には何か別の条件があるはずだ。
自分で調べてもよくわからなかったので、Claudeに「arrow functionでメモリリークが起きる条件って何?」と聞いてみた。うだうだといろいろ答えてくれなかったが、問い詰めると、「evalがあるとV8の最適化が無効になってリークする」と教えてくれた。
検証2: evalを試す
for (let i = 0; i < ITERATIONS; i++) {
const someInstance = new SomeClass();
const largeData = createLargePayload();
eval("0"); // ← これを追加
addListener(() => someInstance.someFunction());
setTimeout(() => someInstance.someFunction(), MS);
}
before: heap=3.38MB
after: heap=2004.43MB
2GB。再現した。evalすげぇ。eval怖ぇ。
なぜevalでリークするのか
なぜevalがあるとリークするのか。Claudeくんを問い詰めていくと、V8には closure context sharingという仕組みがあるらしい。
arrow functionなどのクロージャは、親スコープの変数にアクセスするためにContextオブジェクトを保持する。通常、V8はスコープを静的に解析して、クロージャが実際に参照している変数だけをContextに入れるという最適化をしている。だから検証1ではlargeDataはContextに含まれず、GCが回収できた。
しかしevalがあると話が変わる。evalは実行時に任意のコードを実行できるため、どの変数が参照されるか静的に判断できない。そのためV8は最適化を諦めてスコープ内の全変数をContextに載せてしまう。
そして、このcontextはevalとarrow functionで共通だからevalのscopeがおわってarrow functionだけが残っていてもlargeDataが保持されたままになった
でも、evalなんて本番で使うわけがない
evalでリークというか予期せぬ保持をすることはわかった。でも本番コードでevalを使うことはまずない。だけどさっきのcontext sharingのはなしであれば、evalでなくてもarrowが2つあれば再現しそう
スコープ {
largeData ← 大きなデータ
someInstance ← 小さなオブジェクト
closureA = () => someInstance.method() ─┐
├── 共有Context
closureB = () => largeData ─┘
}
closureAはsomeInstanceしか使っていないのに、closureBがlargeDataを参照しているせいで、共有ContextにlargeDataが含まれる。結果、closureAが生き続ける限りlargeDataも回収されない。
つまり、同じスコープにlargeDataを参照する別のクロージャがあれば、evalがなくても同じことが起きるはず。
検証3: 別のクロージャを追加してみる
largeDataを参照するだけのクロージャtouchesを追加してみる。
for (let i = 0; i < ITERATIONS; i++) {
const someInstance = new SomeClass();
const largeData = createLargePayload();
const touches = () => { return largeData; }; // ← これを追加
addListener(() => someInstance.someFunction());
setTimeout(() => someInstance.someFunction(), MS);
}
touches自体はどこにも渡されていない。ループを抜ければ参照が消える。でもlargeDataがContextに載ったことで、addListenerやsetTimeoutに渡したarrow functionが生き続ける限り、largeDataも道連れになる。
before: heap=3.38MB
after: heap=2004.35MB
2GB。eval無しでも再現した。これが元のPostで起きていた現象かな
検証4: bindで解消するか
元のPostではarrow functionをbindに変えることで解消していた。同じことを試す。
for (let i = 0; i < ITERATIONS; i++) {
const someInstance = new SomeClass();
const largeData = createLargePayload();
const touches = () => { return largeData; };
const fn = someInstance.someFunction.bind(someInstance);
addListener(fn);
setTimeout(fn, MS);
}
bindはクロージャを作らない。Contextを共有しないので、touchesがスコープを抜けた時点でlargeDataも回収可能になる。
before: heap=3.38MB
after: heap=3.87MB
解消した。元のPostの修正がこれで再現できた。
結果一覧(Node / Bun比較)
全パターンの結果をまとめた。
| パターン | 説明 | Node v24 | Bun 1.3.8 |
|---|---|---|---|
| arrow-no-touches | largeDataはスコープにあるが参照クロージャなし | +0.69MB | +12.34MB |
| arrow-with-touches | 別クロージャがlargeData参照 | +2001MB | +580MB |
| bind-with-touches | bind使用(クロージャなし) | +0.49MB | +12.14MB |
| arrow-with-eval | eval存在(最適化無効化) | +2001MB | +562MB |
| arrow-no-largedata | スコープにlargeData自体なし | +0.69MB | +12.06MB |
| arrow-direct-ref | arrow自身がlargeData直接参照 | +2001MB | +637MB |
claudeはBunだろうからBunでもやったけど傾向は同じ(メモリ効率すげぇとは思う)。リークするパターン・しないパターンが一致している。これはV8固有の話ではなく、主要なJSエンジンに共通するclosure context sharingの挙動なのだろう。
検証したファイルはこちら
まとめ
evalは存在するだけで悪
Discussion
Bun の JavaScript エンジンは V8 ではなく JSC ですね。
おっしゃるとおり、一般的に JavaScript エンジンの GC の実装にこの問題があります。どのエンジンも速度とのトレードオフなどの関係で無駄にメモリを使っているというのは正しいです(これは仕様で許されています)。TC39 メンバーの Rob Palmer さんのツイートが参考になるかと思います。
なお JSC の GC を設計した方の見解によると、これは JSC のバグとのことです。
ありがとうございます!
検証は基本nodejsで実施 -> 最後にbunでも同じ事が起きるか?で確認してたので、記事途中ではV8になってました。 V8/JSCどちらも同じ現象、ということで一部修正します
参考ツイートもありがとうございます。
仕様上許されているこの挙動をバグ扱いするかは意見が分かれそうな気もしますが、Go言語ではContextは共有されず個別に作られる / Pythonは共通で作らられるようですので、コンパイルしないスクリプト言語として、速度とのトレードオフでもあったのかな、と思っています。
余談です。まだ開発途中なのですが、JavaScript からネイティブバイナリ(や WebAssembly)を吐き出すコンパイラ Porffor というものがあります。実装を見てはいないですが(セルフホストできるように JavaScript で実装されています)、これを用いた場合は Go のような挙動になりそうですね。