我这里先不说和C之间的性能差距,而是展开说JavaScript递归优化问题,有人问我为什么不说性能差距,我:???这个问题就跟问地球为什么是圆的一样(明摆着)
传统的递归函数,比如:
function a(){
return a()
}
这是一个经典的递归,在函数a内部调用自身,调用栈的机制如下:每调用一个函数,解释器就会把该函数添加进调用栈并开始执行.
正在调用栈中执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈,一旦这个函数被调用,便会立即执行.
当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码.
当分配的调用栈空间被占满时,会引发“堆栈溢出”错误.
这里我们做一个测试,尝试运行这个递归函数,找出调用栈深度:
let index = 0
function a(){
index += 1
console.log(index)
return a()
}
执行结果如下:
// 省略多行输出
> 12559
> 12560
> 12561
> 12562
> 12563
> Uncaught RangeError: Maximum call stack size exceeded
at console.X.t. [as log] (init.js:1)
这里可以看出,当递归深度达到12563的时候,调用栈爆掉了,平时使用是没有问题的,但是对于某些特殊或者极端情况,你又有相似需求的情况下,怎么样突破这个调用栈限制,做到永不爆栈呢?这里需要一个比较hack的写法, 我们对上面的递归函数进行一下改造:
let index = 0
async function a(){
await undefined
index += 1
console.log(index)
return await a()
}
我们来执行一下看一下结果:
// 省略多行输出
> 100590
> 100591
> 100592
// 省略多行输出
事实上,这个递归函数永远不会停止,它会一直执行下去,也没有爆栈,这是一个神奇的优化,可以让你写出非常大深度的递归而不会出现问题,这个优化的关键就是:
async function() { await undefined }
首先将递归函数改为async函数,然后在内部最好第一行 await undefined;
这个操作的原理就是:
1, async创建微任务队列,然后执行器执行当前队列.
2,此时遇到await undefined,其实这个写法等同于await (async () => {})和await Promise.resolve(setTimeout)这几种写法效果等同,用unedfined只是为了在实现同样效果的情况下更简洁,既然已经等同了,那就从这三个写法分析起.
3,此时,执行器发现第一个任务完全没有等待,马上完成了,但是执行器发现后面的任务是需要等待的,并不会马上完成.
4,这时候执行器为了microtask(也就是协程)调度的合理优化,不会让这个微任务队列始终占有这个execution,而是会把当前微任务队列转移到别的execution去执行(您几位走得慢,请去那边空闲的地方走).
5,转移execution带来的操作就是,因为没办法直接转移调用栈,所以会先将当前调用栈入堆,然后把任务队列转移到别的execution.
6,然后队列里面接下来的任务全部都是使用新创建的execution去执行.
这个操作的本意就是为了让当前栈入堆,而且这个写法在C#和Kotlin里面是完全通用的,因为这3个语言的异步方案都是基本类似,而这个写法来自Rust群一位群友的发现,当时我看到这种写法的时候也表示了惊奇,然后对于递归大面积使用这种写法,目前没有发现什么问题.
这两天发现有人@星风雪月对执行机制有很深的成见,所以我这里使用Node.JS的async hooks做了一下异步执行的调试,这是测试代码:
const async_hooks = require("async_hooks")
let index = 0
let print_buffer = ""
/*** async hooks会追踪async调用,* 而console.log使用异步输出,* 所以这里使用同步方法模拟console*/
function println(log) {
print_buffer += log + "\n"
}
/* 创建钩子 */
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
const eid = async_hooks.executionAsyncId()
println("init: *********************************")
println("init: triggerAsyncId " + triggerAsyncId)
println("init: executionAsyncId " + eid)
println("init: asyncId " + asyncId)
println("init: type " + type)
}
})
.enable()
/*********** 测试区 **********/
async function A() {
/* 为了观察方便只执行2次 */
println("A: runing")
index += 1
if (index === 2) return undefined
// 有优化递归 await undefined
return await A()
}
/*********** 测试区 **********/
A().then(() => {
console.log(print_buffer)
})
执行之后的输出:
init: *********************************
init: triggerAsyncId 1
init: executionAsyncId 1
init: asyncId 2
init: type PROMISE
A: runing
init: *********************************
init: triggerAsyncId 2
init: executionAsyncId 1
init: asyncId 3
init: type PROMISE
init: *********************************
init: triggerAsyncId 3
init: executionAsyncId 1
init: asyncId 4
init: type PROMISE
init: *********************************
init: triggerAsyncId 2
init: executionAsyncId 1
init: asyncId 5
init: type PROMISE
init: *********************************
init: triggerAsyncId 4
init: executionAsyncId 4
init: asyncId 6
init: type PROMISE
A: runing
init: *********************************
init: triggerAsyncId 6
init: executionAsyncId 4
init: asyncId 7
init: type PROMISE
这里看executionAsyncId标志,这个是当前执行器的ID,这里可以看到当经过await undefined之后, 执行器从1变成了4,说明这里发生了execution转移,下面我修改一下代码,变成不优化的写法:
// 无优化递归// await undefinedreturn await A()
我这里将await undefined删除,再来执行看看会是什么情况:
init: *********************************
init: triggerAsyncId 1
init: executionAsyncId 1
init: asyncId 2
init: type PROMISE
A: runing
init: *********************************
init: triggerAsyncId 1
init: executionAsyncId 1
init: asyncId 3
init: type PROMISE
A: runing
init: *********************************
init: triggerAsyncId 3
init: executionAsyncId 1
init: asyncId 4
init: type PROMISE
init: *********************************
init: triggerAsyncId 2
init: executionAsyncId 1
init: asyncId 5
init: type PROMISE
再看executionAsyncId,这里始终是使用1去执行,所以没有转移execution执行,这里就很能说明问题了,await undefined可以转移执行器执行,让当前栈入堆,这样可以使调用栈不会溢出,达到深递归优化的目的.
对于评论区朋友说明,这种方式会阻塞macrotask,所以不推荐这种写法,我这里并不表示完全反对意见,我这里来说一下我自己的看法:
异步任务归属microtask,而其他事件回调归属macrotask,microtask的优先级本身就比macrotask要高,所以肯定是microtask先执行,然后才轮到macrotask,像setTimeout(0)这种本身就是属于macrotask,肯定要等到microtask执行完成之后才能执行,不过这确实会带来一个问题,就是对已经运行的macrotask产生时间分辨率精度影响,比如定时器偏移,定时器不会精准得按时间分片执行任务,所以这种写法见仁见智,你如果需要精确macrotask执行的场景还是慎用.