















Me, Surma, and Jason were hacking on a thing, and discovered that garbage collection within a function doesn't quite work how we expected.
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();With the above, bigArrayBuffer is leaked forever. I didn't expect that, because:
bigArrayBuffer is no longer callable.bigArrayBuffer.But that doesn't matter. Here's why:
This doesn't leak:
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
console.log(bigArrayBuffer.byteLength);
}
demo();The function executes, bigArrayBuffer is no longer needed, so it's garbage collected.
This also doesn't leak:
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
}
demo();In this case:
bigArrayBuffer is referenced by inner functions, so it's kept around. It's associated with the scope that was created when demo() was called.bigArrayBuffer is no longer callable.bigArrayBuffer.This also doesn't leak:
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
const id = setTimeout(() => {
console.log('hello');
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();In this case, the engine knows it doesn't need to retain bigArrayBuffer, as none of the inner-callables access it.
Here's where it gets messy:
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength);
}, 1000);
return () => clearTimeout(id);
}
globalThis.cancelDemo = demo();This leaks, because:
bigArrayBuffer is referenced by inner functions, so it's kept around. It's associated with the scope that was created when demo() was called.bigArrayBuffer is no longer callable.bigArrayBuffer is associated with the scope, so it remains in memory.I thought engines would be smarter, and GC bigArrayBuffer since it's no longer referenceable, but that isn't the case.
globalThis.cancelDemo = null;Now bigArrayBuffer can be GC'd, since nothing within the scope is callable.
This isn't specific to timers, it's just how I encountered the issue. For example:
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
globalThis.innerFunc1 = () => {
console.log(bigArrayBuffer.byteLength);
};
globalThis.innerFunc2 = () => {
console.log('hello');
};
}
demo();
// bigArrayBuffer is retained, as expected.
globalThis.innerFunc1 = undefined;
// bigArrayBuffer is still retained, as unexpected.
globalThis.innerFunc2 = undefined;
// bigArrayBuffer can now be collected.TIL!
I originally thought this 'capturing' of values only happened for functions that outlive the initial execution of the parent function, but that isn't the case:
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000);
(() => {
console.log(bigArrayBuffer.byteLength);
})();
globalThis.innerFunc = () => {
console.log('hello');
};
}
demo();
// bigArrayBuffer is retained, as unexpected.Here, the inner IIFE is enough to trigger the leak.
This whole thing is an issue across browsers, and is unlikely to be fixed due to performance issues.
eval()Folks on Hacker News and Twitter were quick to point out that this is all because of eval(), but it isn't.
Eval is tricky, because it means code can exist within a scope that can't be statically analysed:
function demo() {
const bigArrayBuffer1 = new ArrayBuffer(100_000_000);
const bigArrayBuffer2 = new ArrayBuffer(100_000_000);
globalThis.innerFunc = () => {
eval(whatever);
};
}
demo();Are either of the buffers accessed within innerFunc? There's no way of knowing. But the browser can statically determine that eval is there. This causes a deopt where everything in the parent scopes is retained.
The browser can statically determine this, because eval acts kinda like a keyword. In this case:
const customEval = eval;
function demo() {
const bigArrayBuffer1 = new ArrayBuffer(100_000_000);
const bigArrayBuffer2 = new ArrayBuffer(100_000_000);
globalThis.innerFunc = () => {
customEval(whatever);
};
}
demo();…the deopt doesn't happen. Because the eval keyword isn't used directly, whatever will be executed in the global scope, not within innerFunc. This is known as 'indirect eval', and MDN has more on the topic.
This behaviour exists specifically so browsers can limit this deopt to cases that can be statically analysed.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。