一个永远不会完成的 Promise 是否会造成存储泄漏

4,350 阅读9分钟

Promise已经火了很多年了,网上的各种对Promise的解读也非常之多,从A+规范到手撕源码等系列的文章也是层出不穷,都是在扒一些底层原理,对于我们实际的业务遇到的问题以及使用细节和场景等方面的讨论却是相对较少的。

今天就来聊一聊一个比较特殊的问题,那就是一个永远不会完成的Promise是否会造成存储泄漏。

省流结论:内存泄漏是你的代码写的有问题,而不是Promise的问题,Promise本身是不会造成内存泄漏。

为什么会有这个问题

根据Promise的特性,我们知道Promise有三种状态:pendingfulfilledrejected,而且Promise的状态是不可逆的,一旦Promise的状态发生改变,就会一直保持这个状态,直到PromiseGC回收。

我们在使用的过程中,通常是如下的使用方式:

const asyncFunc = () => new Promise((resolve, reject) => {
  // 模拟异步操作
  setTimeout(() => {
    resolve('success')
  }, 1000)
})

asyncFunc().then(res => {
  console.log(res)
})

这一切看上去都非常好,经过1s后,Promise状态变为fulfilled,然后执行then中的回调函数,打印出success,这个Promsie结束了它的生命周期,等待GC回收。

现在开始假设我们的异步操作永远不会完成,那么Promise的状态就会一直保持pending,我们担心的就是这个Promise会不会一直占用内存,导致内存泄漏。

const asyncFunc = () => new Promise((resolve, reject) => {
  // 模拟异步操作
  // setTimeout(() => {
  //   resolve('success')
  // }, 1000)
})

asyncFunc().then(res => {
  console.log(res)
})

如何验证

验证内存是否泄漏的方案其实有很多,我们最常见的方式就是通过chromedevtools来查看内存的变化,但是我们的这个案例比较有针对性,我们可以通过queryObjects来进行验证,文档地址:queryObjects

image.png

这个API是可以统计已创建的对象的数量,我们可以通过这个API来查看Promise的数量,如果Promise的数量一直在增加,那么就说明Promise一直在占用内存,导致内存泄漏。

还是上面的例子,我们写一个循环不断地创建Promise,然后查看Promise的数量:

const asyncFunc = () => new Promise((resolve, reject) => {
  // 模拟异步操作
  // setTimeout(() => {
  //   resolve('success')
  // }, 1000)
})

const sleep = (time) => new Promise((resolve) => {
  setTimeout(resolve, time)
})

const main = async () => {
  let count = 1;
  while (count++) {
    asyncFunc().then(res => {
      console.log(res)
    })
    await sleep(5000)
    console.log('循环创建Promise', count)
  }
}
main()

image.png

通过上面的测试代码可以看到,Promise的数量一直在增加,说明Promise一直在占用内存,这样看上去就是内存泄漏了。

这三个Promise就是我们在代码中创建的,一个是asyncFunc,一个是sleep,一个是main函数,因为main函数被标记了async所以也是一个Promise,截图中的每次递增的2个Promise就是asyncFuncsleep

不过上面的示例有一个小问题,这个也是devtools老bug了,就是控制台输出的对象会一直保持引用,如果我们清空控制台,再次执行queryObjects,就会发现Promise的数量并没有增加。

可以执行下面的代码,清空控制台后再次执行queryObjects

console.clear()
queryObjects(Promise)

image.png

可以看到清空控制台后,再次执行queryObjectsPromise的数量就不再增加了,而是一直保持3个。

注意:queryObjects需要手动在devtoolsconsole中执行,而且只能在devtools中执行,不能在代码中执行。

为了防止原有网页中会使用到Promise,影响我们的测试结果,可以新建一个空白的html文件在浏览器中打开,然后在devtools中执行我们的测试代码。

垃圾回收机制

为什么会一直保持3个Promise呢,这就关系到V8的垃圾回收机制了,具体的垃圾回收机制这里就不展开了,想要稍微了解一下的可以看看我的这篇文章:业务仔就写好业务,内存泄漏不是你该关心的问题

我们需要知道的是浏览器的垃圾回收机制是基于引用计数的,也就是说只要有引用指向这个对象,这个对象就不会被回收,就例如上面的验证的时候,控制台输出的对象就是一个引用,所以这个对象就不会被回收。

本身浏览器在什么时候发生垃圾回收是不确定的,但是如果我们在调用queryObjects时,浏览器是会主动触发一次垃圾回收的,如何知道浏览器是否发生了垃圾回收呢,可以通过FinalizationRegistry来监听:

const registry = new FinalizationRegistry((heldValue) => {
    console.log('垃圾回收了', heldValue)
});

// ...

const main = async () => {
    let count = 1;
    while (count++) {
        const p1 = asyncFunc().then(res => {
            console.log(res)
        })
        registry.register(p1, 'p' + count);
        
        await sleep(5000)
        console.log('循环创建Promise', count)
    }
}
main()

文档地址:FinalizationRegistry

image.png

通过上面的验证可以确认queryObjects会触发一次垃圾回收,不过会发现Promise的数量变成4个了,这是因为变量p1接收了asyncFunc().then(res => { console.log(res) })的返回值,之前并没有接收,所以这个Promise一直都属于0引用,现在有了引用,所以Promise的数量就变成4个了。

不需要担心一个永远不会完成的 Promise

通过上面的验证可以看到,一个永远不会完成的Promise并不会造成内存泄漏,我们担心的点可能是在于使用Promise的时候,如果这个Promise一直保持pending状态,那么我们注册的回调函数就不会被执行,这个回调函数是否会一直保持引用,导致内存泄漏。

我们继续验证这个问题,不过这次我们不使用Promise来验证,而是模拟一个Promise的类似的场景:

function MockPromise(fn) {
  const thenCallbacks = []
  
    this.then = (cb) => {
        thenCallbacks.push(cb)
    }
    
    const resolve = (value) => {
        thenCallbacks.forEach(cb => cb(value))
    }
    
    fn(resolve)
}

function MyCallback() {
}

(() => {
    const mp = new MockPromise((resolve) => {
        // 不去调用resolve
    })

    let count = 10;
    while (count--) {
        mp.then(new MyCallback(count))
    }
    
    // 保留引用
    console.log(mp)
})()

通过上面的代码可以看到,我们模拟了一个Promise的场景,MockPromise类似于PromiseMyCallback类似于then中的回调函数;

因为MyCallback是被new出来的,如果调用resolve肯定是会报错的,因为queryObjects统计的是对象的实例,所以只有通过new出来的对象才会被统计,我们只是通过这种方式来看看MyCallback是否会一直保持引用。

image.png

可以看到在数量上是没有问题的,MyPromise的数量是1MyCallback的数量是10,因为MyPromise的引用是保留在控制台中的,而MyCallback的引用则是保留在MockPromise中的,现在只需要清空控制台,再次执行queryObjects,来看看结果:

image.png

可以看到最后的结果是全都被回收了,MyPromise的数量是0MyCallback的数量是0,为什么要用这个例子来验证呢?这个例子和Promise好像没有太大的关系。

那我们换成Promise来验证一下:

(() => {
    const p = new Promise((resolve) => {
        // 不去调用resolve
    })

    let count = 10;
    while (count--) {
        // Promise 的回调必须是一个函数,如果不是函数会被忽略,这里为了记录引用所以包装一下
        const cb = new MyCallback(count);
        p.then(() => cb)
    }
    
    // 保留引用
    console.log(p)
})()

image.png

这里有两个细节需要注意:

  1. Promise的回调必须是一个函数,如果不是函数会被忽略。
  2. Promisethen方法会返回一个新的Promise

所以我们查询到的Promise的数量是11,循环创建添加了10个回调函数,和原本的Promise一共是11个;我们注册的回调函数包含了MyCallback,所以MyCallback的数量是10

和上面的验证一样,清空控制台后再次执行queryObjects

image.png

可以看到最后的结果是全都被回收了,Promise的数量是0MyCallback的数量是0,这就说明了一个永远不会完成的Promise不会造成内存泄漏。

什么情况下会造成内存泄漏

为什么我要使用两个例子来验证这个问题呢?其实这涉及到一个很基础的问题,不管是Promise还是其他的对象,只要这个对象没有被引用,那么这个对象就会被回收,不会造成内存泄漏。

第一个模拟的例子其实和Promise之间的关联就在于注册的回调函数是存在于MockPromise/Promise中,只要MockPromise/Promise被回收,那么这个回调函数也会被回收,不会造成内存泄漏。

用这个例子做对比其实想说的是内存泄漏的问题不在于Promise,而在于我们的代码写的有问题,比如下面的例子:

let count = 0;
const map = {};

const asyncFunc = () => new Promise((resolve, reject) => {
    let id = count++;
    map[id] = {
        resolve,
        reject,
        id
    }
    
    setTimeout(() => {
        onResolve(id)
    }, Math.random() * 1000)
})

const onResolve = (id) => {
    map[id].resolve('success')
}

const main = async () => {
    let count = 10;
    while (count--) {
        asyncFunc().then(res => {
            console.log(res)
        })
    }
}
main()

image.png

可以看到所有的Promise都是完成状态,当前存在的Promise的数量是11,这个数量是没有问题的,接着还是清空控制台,再次执行queryObjects

image.png

可以看到最后的结果是Promise的数量是10,回收的一个Promisemain函数的Promise,由此可以看出内存泄漏的问题并不在于Promise是否完成,而在于我们代码怎么写的;

上面的这个示例我是在真实存在的场景中提取出来的,部分业务代码中会有这样的写法,因为要保证每个Promise的回调能够被正确的执行,所以会将resolve/reject保存在一个map中,而很多开发者却会忽略掉已经完成的Promise是否还需要保留引用。

类似的问题还有下面的这个例子:

const asyncFunc = () => new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('success')
    }, Math.random() * 1000)
})

const tasks = []
const main = async () => {
    let count = 10;
    while (count--) {
        const p = asyncFunc()
        tasks.push(p)
    }
}
main()

这个例子和上面的例子类似,感兴趣的可以自己验证一下。

总结

对于Promise来说,不管是保留了Promise本身的实例的引用,还是保留了executor中的resolve/reject的引用,只要没有释放掉这个引用,那么这个Promise就不会被回收,和Promise是否完成没有关系。

内存泄漏的本身原因还是在于我们的代码写的有问题,基于上面的案例来讲,只要我们在使用Promise的时候,保证Promise的引用被释放掉,那么就不会造成内存泄漏。