Lookup table with weak references in Javascript

Viewed 323

I have a tree structure with elements dynamically added and removed. The elements are loaded dynamically from the network. What I want to achieve is to have a lookup table that maps the element's id to the actual element in the tree. Now, the problem when using a simple Map or Object is that it holds strong references to the tree elements which will bloat the memory after a while. Since node >= 14.6.0 and Chrome >= 84 supposedly support WeakRef's I thought I could make a Map that holds WeakRefs to my tree elements and then simply deref() and see if the elements are still around. I tried to test this but it doesn't seem to work. My minimal test looks like this:

const lookup = new Map();
let element = new Object({id:"someid", data: {}});

lookup.set(element.id, new WeakRef(element));
console.dir(lookup.get("someid").deref());
// as expected output is { id: 'someid', data: {} }

element = null;
console.log(element);
// as expected output is null

// simply calling global.gc() didn't work
// so i made this loop which allocates mem *and* calls global.gc() to
// force garbage collection
// problem: infinite loop because deref always returns the dereferenced
// value which should have gone since element was set to null

while (lookup.get("someid").deref()) {
  const a = new Array(1000);
  // enabled with --expose-gc in node
  global.gc();
}

console.dir(lookup.get("someid").deref());

As written above in the comment, the issue is that the loop never ends because the deref call always returns a value despite the element var being set to null.

Am I missing something here? If not and this is how it is supposed to work, how can I achieve my goal of having a map of weak references (WeakMap is not an option here, since I would have an O(n) cost of looking up an element by id)?.

3 Answers

The real issue here is that calling global.gc() does not run a full GC pass. In my tests below, only when I allowed 10 seconds of idle time did I get full GC.

Here are some observations about your specific code. If I add a single await delay(5000) pause in the GC, then your object is still not GCed before the while loop. But, if I add two await delay(5000) statements or one await delay(10000) statement, it is GCed before the while loop. So, the GC is clearly timing sensitive and calling global.gc() is apparently not a full run of GC. For example, here's a version of your code where the weakref is GCed!

function delay(t, v) {
    return new Promise(resolve => {
        setTimeout(resolve, t, v);
    });
}

async function run() {

    const lookup = new Map();
    let element = new Object({ id: "someid", data: {} });

    lookup.set(element.id, new WeakRef(element));
    console.dir(lookup.get("someid").deref());
    // as expected output is { id: 'someid', data: {} }

    element = null;
    await delay(10000);
    console.log(element);
    // as expected output is null

    // if above is delay(5000), then it logs "in while loop"
    // if above is delay(10000), then it does NOT log "in while loop"
    // so the amount of time is important to allow the GC to do its thing
     
    while (lookup.get("someid").deref()) {
        console.log("in while loop");
        break;
    }

    console.dir(lookup.get("someid").deref());
}

run();

Before I discovered that your code would GC with a delay, I set out to just run an experiement to see whether a WeakRef works or not. This is the code that showed (that with the right delays to allow full GC), the WeakRef does work in node v14.15.

Here's my test code:

// to make memory usage output easier to read
function addCommas(str) {
    var parts = (str + "").split("."),
        main = parts[0],
        len = main.length,
        output = "",
        i = len - 1;

    while (i >= 0) {
        output = main.charAt(i) + output;
        if ((len - i) % 3 === 0 && i > 0) {
            output = "," + output;
        }
        --i;
    }
    // put decimal part back
    if (parts.length > 1) {
        output += "." + parts[1];
    }
    return output;
}

function delay(t, v) {
    return new Promise(resolve => {
        setTimeout(resolve, t, v);
    });
}

function logUsage() {
    let usage = process.memoryUsage();
    console.log(`heapUsed: ${addCommas(usage.heapUsed)}`);
}

const numElements = 10000;
const lenArrays = 10000;

async function run() {

    const cache = new Map();
    const holding = [];

    function checkItem(n) {
        let item = cache.get(n).deref();
        console.log(item);
    }

    // fill all the arrays and the cache
    // and put everything into the holding array too
    let arr, element;
    for (let i = 0; i < numElements; i++) {
        arr = new Array(lenArrays);
        arr.fill(i);
        element = { id: i, data: arr };

        // temporarily hold onto each element by putting a
        // full reference (not a weakRef) into an array
        holding.push(element);

        // add a weakRef to the Map
        cache.set(i, new WeakRef(element));
    }
    // clean up locals we don't need any more
    element = array = null;

    // should have a big Map holding lots of data
    // all items should still be available
    checkItem(numElements - 1);
    logUsage();

    await delay(5000);
    logUsage();

    // make whole holding array contents eligible for GC
    holding.length = 0;

    // pause for GC, then see if items are available
    // and what memory usage is
    await delay(5000);
    checkItem(0);
    checkItem(1);
    checkItem(numElements - 1);

    // count how many items are still in the Map
    let cnt = 0;
    for (const [index, item] of cache) {
        if (item.deref()) {
            ++cnt;
            console.log(`Index item ${index} still in cache`);
        }
    }
    console.log(`There are ${cnt} items that haven't be GCed in the map`);
    logUsage();
}

run();

And, the output I get is this:

{
  id: 9999,
  data: [
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999, 9999,
    ... 9900 more items
  ]
}
heapUsed: 806,706,120
heapUsed: 806,679,456
undefined
undefined
undefined
There are 0 items that haven't be GCed in the map
heapUsed: 3,412,144

The two undefined lines in the output and the last heapUsed show that the objects wrapped in weakRef references did get GCed.

So, after enough of a time delay with nothing else for the interpreter to do, data with only a weakRef does appear to be GCed. I don't know yet exactly why your example is not showing that except that my experience has shown that merely calling global.gc() does not necessarily do all the same GC that an actual idle interpreter will do. So, I'd suggest you insert a legit pause (like I'm doing in my example) and see if you do eventually get the memory back.


P.S. I posted this other question about a GC anomaly I discovered while working on this answer.

Am I missing something here?

Yes: you're missing the notes in the documentation you've linked to, for instance:

If your code has just created a WeakRef for a target object, or has gotten a target object from a WeakRef's deref method, that target object will not be reclaimed until the end of the current JavaScript job (including any promise reaction jobs that run at the end of a script job). That is, you can only "see" an object get reclaimed between turns of the event loop.

And of course:

Avoid where possible
Correct use of WeakRef takes careful thought, and it's best avoided if possible. It's also important to avoid relying on any specific behaviors not guaranteed by the specification. When, how, and whether garbage collection occurs is down to the implementation of any given JavaScript engine.

That said, achieving your goal is totally possible; your test case is just too simple (in light of the notes quoted above) to show it. Here's a fixed version:

const lookup = new Map();

(function () {
  let element = { id: "someid", data: {} };
  lookup.set(element.id, new WeakRef(element));
  element = null;

  console.log(lookup.get("someid").deref());

  setTimeout(() => {
    global.gc();
    console.log(lookup.get("someid").deref());
  }, 0);
})();

I think your code works fine (except for the while loop and global.gc(), of course). If you run this test page in Chrome, and you wait a while, eventually it logs in the console that many WeakRefs have indeed been garbage-collected: https://output.jsbin.com/momelej

Related