惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

雷峰网
雷峰网
爱范儿
爱范儿
宝玉的分享
宝玉的分享
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - Franky
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 三生石上(FineUI控件)
人人都是产品经理
人人都是产品经理
阮一峰的网络日志
阮一峰的网络日志
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Last Week in AI
Last Week in AI
博客园 - 聂微东
大猫的无限游戏
大猫的无限游戏
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
罗磊的独立博客
博客园 - 叶小钗
WordPress大学
WordPress大学
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
酷 壳 – CoolShell
酷 壳 – CoolShell
小众软件
小众软件
博客园 - 司徒正美
博客园 - 【当耐特】
IT之家
IT之家

Jake Archibald's blog

Controlling when CSS custom properties are computed Fixing my tooltip accessibility mistake The Goldilocks customizable select height Importing vs fetching JSON The present and potential future of progressive image rendering Fetch streams are great, but not for measuring upload/download progress Making XML human-readable without XSLT Give footnotes the boot Animating zooming using CSS: transform order is important… sometimes Firefox + custom elements + iframes bug How should <​selectedoption​> work? Video with alpha transparency on the web
Garbage collection and closures
2024-07-30 · via Jake Archibald's blog

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:

  • After a second, the function referencing bigArrayBuffer is no longer callable.
  • The returned cancel function doesn't reference bigArrayBuffer.

But that doesn't matter. Here's why:

JavaScript engines are reasonably smart

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:

  1. The engine sees bigArrayBuffer is referenced by inner functions, so it's kept around. It's associated with the scope that was created when demo() was called.
  2. After a second, the function referencing bigArrayBuffer is no longer callable.
  3. Since nothing within the scope is callable, the scope can be garbage collected, along with 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.

The problem case

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:

  1. The engine sees bigArrayBuffer is referenced by inner functions, so it's kept around. It's associated with the scope that was created when demo() was called.
  2. After a second, the function referencing bigArrayBuffer is no longer callable.
  3. But, the scope remains, because the 'cancel' function is still callable.
  4. 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!

Updates

An IIFE is enough to trigger the leak

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.

It's a cross-browser issue

This whole thing is an issue across browsers, and is unlikely to be fixed due to performance issues.

I'm not the first to write about this

And no, this is not due to 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.

View this page on GitHub