Promise
已经火了很多年了,网上的各种对Promise
的解读也非常之多,从A+
规范到手撕源码等系列的文章也是层出不穷,都是在扒一些底层原理,对于我们实际的业务遇到的问题以及使用细节和场景等方面的讨论却是相对较少的。
今天就来聊一聊一个比较特殊的问题,那就是一个永远不会完成的Promise
是否会造成存储泄漏。
省流结论:内存泄漏是你的代码写的有问题,而不是Promise
的问题,Promise
本身是不会造成内存泄漏。
为什么会有这个问题
根据Promise
的特性,我们知道Promise
有三种状态:pending
、fulfilled
、rejected
,而且Promise
的状态是不可逆的,一旦Promise
的状态发生改变,就会一直保持这个状态,直到Promise
被GC
回收。
我们在使用的过程中,通常是如下的使用方式:
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)
})
如何验证
验证内存是否泄漏的方案其实有很多,我们最常见的方式就是通过chrome
的devtools
来查看内存的变化,但是我们的这个案例比较有针对性,我们可以通过queryObjects
来进行验证,文档地址:queryObjects
这个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()
通过上面的测试代码可以看到,Promise
的数量一直在增加,说明Promise
一直在占用内存,这样看上去就是内存泄漏了。
这三个Promise
就是我们在代码中创建的,一个是asyncFunc
,一个是sleep
,一个是main
函数,因为main
函数被标记了async
所以也是一个Promise
,截图中的每次递增的2个Promise
就是asyncFunc
和sleep
。
不过上面的示例有一个小问题,这个也是devtools
的老bug
了,就是控制台输出的对象会一直保持引用,如果我们清空控制台,再次执行queryObjects
,就会发现Promise
的数量并没有增加。
可以执行下面的代码,清空控制台后再次执行queryObjects
:
console.clear()
queryObjects(Promise)
可以看到清空控制台后,再次执行queryObjects
,Promise
的数量就不再增加了,而是一直保持3个。
注意:
queryObjects
需要手动在devtools
的console
中执行,而且只能在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
通过上面的验证可以确认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
类似于Promise
,MyCallback
类似于then
中的回调函数;
因为MyCallback
是被new
出来的,如果调用resolve
肯定是会报错的,因为queryObjects
统计的是对象的实例,所以只有通过new
出来的对象才会被统计,我们只是通过这种方式来看看MyCallback
是否会一直保持引用。
可以看到在数量上是没有问题的,MyPromise
的数量是1
,MyCallback
的数量是10
,因为MyPromise
的引用是保留在控制台中的,而MyCallback
的引用则是保留在MockPromise
中的,现在只需要清空控制台,再次执行queryObjects
,来看看结果:
可以看到最后的结果是全都被回收了,MyPromise
的数量是0
,MyCallback
的数量是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)
})()
这里有两个细节需要注意:
Promise
的回调必须是一个函数,如果不是函数会被忽略。Promise
的then
方法会返回一个新的Promise
。
所以我们查询到的Promise
的数量是11
,循环创建添加了10
个回调函数,和原本的Promise
一共是11
个;我们注册的回调函数包含了MyCallback
,所以MyCallback
的数量是10
。
和上面的验证一样,清空控制台后再次执行queryObjects
:
可以看到最后的结果是全都被回收了,Promise
的数量是0
,MyCallback
的数量是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()
可以看到所有的Promise
都是完成状态,当前存在的Promise
的数量是11
,这个数量是没有问题的,接着还是清空控制台,再次执行queryObjects
:
可以看到最后的结果是Promise
的数量是10
,回收的一个Promise
是main
函数的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
的引用被释放掉,那么就不会造成内存泄漏。