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

推荐订阅源

H
Help Net Security
爱范儿
爱范儿
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - 三生石上(FineUI控件)
大猫的无限游戏
大猫的无限游戏
Hugging Face - Blog
Hugging Face - Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Vercel News
Vercel News
人人都是产品经理
人人都是产品经理
G
Google Developers Blog
WordPress大学
WordPress大学
S
SegmentFault 最新的问题
雷峰网
雷峰网
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Jina AI
Jina AI
博客园 - 叶小钗
D
DataBreaches.Net
D
Docker
月光博客
月光博客
博客园 - 司徒正美
Last Week in AI
Last Week in AI
有赞技术团队
有赞技术团队
腾讯CDC
酷 壳 – CoolShell
酷 壳 – CoolShell

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