文章

既未Resolve又未Reject的Promise对象会导致内存泄漏吗?

实际场景中,经常可能出现既不resolve又不rejectPromise对象。

例如:被取消的HTTP请求。

我们知道,JavaScript的内存管理是基于引用计数的,出现上述情况的Promise对象时,并没有显式的方法告知Promise“你将用不到了”,如此理论上如果出现大量这样的Promise对象,将导致内存泄漏。

然而事实是否这样呢?

测试

在NodeJS 12.x环境下,我们测试一下Promise的内存占用情况。

内部无回调的Promise

我们直接看看创建10亿个既不resolve也不reject的Promise对象后,Heap内存的变化情况。

测试脚本

let used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`程序启动时占用内存: ${Math.round(used * 100) / 100} MB`);

global.gc();
used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`启动后GC占用内存: ${Math.round(used * 100) / 100} MB`);

for (let i = 0; i < 1000000000; ++i) {
    new Promise(rs => {
        if (Math.random() === NaN) {  // 构造一个不可能的条件            
            rs();   // 永远执行不到此处,仅为了引用一下rs()
        }
    }).then(() => {
        // 不可能执行到此处
        console.log('never resolved')
    })
};

used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`Promise创建后占用内存: ${Math.round(used * 100) / 100} MB`);

global.gc();
used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`GC后占用内存 ${Math.round(used * 100) / 100} MB`);

运行结果

程序启动时占用内存: 1.99 MB
启动后GC占用内存: 1.78 MB
Promise创建后占用内存: 2.34 MB
GC后占用内存 1.77 MB

创建10亿个未被释放的Promise对象后,内存基本毫无变化。

上面的例子,由于Promise内部函数里并没有任何回调等待和异步调用,所以猜测是不是JS引擎已经做优化,自动将Promise释放了。

考虑到此,我们使用内部有回调等待的场景再来测试一次。

回调未完成的Promise

测试脚本

let used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`程序启动时占用内存: ${Math.round(used * 100) / 100} MB`);

global.gc();
used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`启动后GC占用内存: ${Math.round(used * 100) / 100} MB`);

let rand = Math.random();
let N = 0;
for (let i = 0; i < 1000000; ++i) {
    new Promise(rs => {
        setTimeout(() => {            
            if (rand === 999) {  // 构造一个不可能的条件            
                rs();   // 永远执行不到此处,仅为了引用一下rs()
            }
        }, 86400000);   // 等待24小时后再执行,肯定完成不了了
        ++N;
    }).then(() => {
        console.log('never resolved')
    })
};

setTimeout(() => {
    console.log(N);
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(`Promise创建后占用内存: ${Math.round(used * 100) / 100} MB`);

    global.gc();
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(`GC后占用内存 ${Math.round(used * 100) / 100} MB`);
}, 10000); // 10秒钟后就测量内存,上面24小时的回调必定无法完成

运行结果

程序启动时占用内存: 1.99 MB
启动后GC占用内存: 1.78 MB
1000000
Promise创建后占用内存: 522.05 MB
GC后占用内存 521.98 MB

可见,内部有回调的Promise,是会占用内存的。 并且当内部回调未完成时,这些内存会被持续挂起,即便GC也不会自动释放。 那么如果回调完成,但是依旧既不resolve又不reject,这些内存又会如何呢? 继续测试……

回调已完成的Promise

测试脚本

let used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`程序启动时占用内存: ${Math.round(used * 100) / 100} MB`);

global.gc();
used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`启动后GC占用内存: ${Math.round(used * 100) / 100} MB`);

let rand = Math.random();
let N = 0;
for (let i = 0; i < 1000000; ++i) {
    new Promise(rs => {
        setTimeout(() => {
            ++N;
            if (rand === 999) {  // 构造一个不可能的条件            
                rs();   // 永远执行不到此处,仅为了引用一下rs()
            }
        }, 10)   // 10毫秒后即执行,确保这里的回调肯定执行完成
    }).then(() => {
        console.log('never resolved')
    })
};

setTimeout(() => {
    console.log(N);
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(`Promise创建后占用内存: ${Math.round(used * 100) / 100} MB`);

    global.gc();
    used = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(`GC后占用内存 ${Math.round(used * 100) / 100} MB`);
}, 10000);  // 上面的回调等待10毫秒,这里等待10秒,确保到这里回调肯定执行完成

运行结果

程序启动时占用内存: 1.99 MB
启动后GC占用内存: 1.78 MB
1000000
Promise创建后占用内存: 522.57 MB
GC后占用内存 1.8 MB

可见,内部只要有回调的Promise,就是会占用内存的。 但回调执行完成后,这部分内存的引用计数应该就被清零,所以GC后这部分内存会被自动释放。

结论

  1. 未执行完成的Promise(包括内部等待的回调未完成)会占用内存。

  2. 执行完成的Promise(包括内部等待的回调也执行完成),不占用内存,可被GC释放。

  3. 执行完成的Promise,即便未触发resolve或reject,也可以被GC自动释放掉。

  4. 综上,无需担心既不resolve也不reject的Promise对象会引发内存泄漏。

License:  CC BY 4.0