JavaScript的异步机制 Asynchronous
JavaScript是一个单线程的编程语言,即便它包含了 await
和 async
的关键字也改变不了这个事实。
那么,作为单线程语言的JS是怎么实现异步操作的呢?
Synchronous
首先,先了解一下JS的同步执行方式。例如下面的🌰:
const second = () => {
console.log('Hello there!');
}
const first = () => {
console.log('The Start.');
second();
console.log('The End.');
}
可以想象输出会是:
Start.
Hello World!
End.
这是一个同步调用的例子,那我们来说说JS执行机制背后包含的东西:Execution Context 和 Call Stack
Execution Context 执行上下文
每一段代码都运行在自己的上下文之中。例如,回调函数的上下文就是它被定义的地方,包括可获取的变量。
Call Stack 调用栈
Stack就是一个LIFO先进后出的结构。后进入的函数先被执行和释放。还是上面那个例子,每个函数进入栈的顺序如下图:

所以,发生了什么?
首先,主函数先被运行了,于是主函数包括其上下文Context都被放进了Stack。接着first()
及其上下文也被放进了Stack。由于first
包含了三个子函数,于是console.log('The Start.');
也被放进Stack,且马上被执行,执行完后被释放。同样second()
也被放入、执行和释放。然后console.log(‘The End’)
也被放入、执行和释放。由于first()
的所有内容已经执行完毕,也被释放。最后主函数被释放。
这就是一个完整的同步执行的过程。那么异步执行呢?
Asynchronous
前面也说到了,JS是一个单线程的语言,所以JS并没有办法靠自己完成异步的操作,它需要依赖于浏览器环境或者Node.js环境。先来看一张图:

这里出现了三个新的东西:Event Loop,Web Api 和Message Queue。
注意这三部分都不是属于JavaScript Engine 的范畴。而是属于浏览器JS运行环境的。
在下面这个🌰中:
const networkRequest = () => {
setTimeout(() => {
console.log('Async Code');
}, 2000);
};
console.log('Hello World');
networkRequest();
console.log('The End');
它的执行过程是:

注意到,这里一个显著的区别就是,当setTimeout
被调用的时候,它实际上在Web Api中设置了一个定时器,然后接着执行同步操作。这个计时器可能在同步操作结束之前或者之后结束,但无论如何回调函数都会先被放进Message Queue。
等到Call Stack 中的任务都执行完了之后,Event Loop会来查询Message Queue之中是否有等待被执行的任务。如果有,就取出第一个任务放进Call Stack执行。以此类推,直到Message Queue空了。
Message Queue中同样包含事件响应,如鼠标点击监听等。
Conclusion
所以JS的执行本质是一个调用栈。而它的异步操作实际上是依赖于浏览器当中的JS运行环境来帮助JS把异步调用的返回函数存放在消息队列当中,依赖于Event Loop进行轮训,当Call Stack一空,就把任务从Message Queue中取出。
这样的执行方式也决定了,在一个函数当中,一个异步操作即使是一开头就被执行了,它的回调函数还是必须等到所有同步操作完成了之后才会被调用。
这里也涉及到了另一个问题,就是如果有多个异步操作,那么它们的回调函数执行顺序是怎么样的呢?
简洁的答案是:如果所有的异步回调函数都在同一个队列之中,如Message Queue,那么FIFO先进先出。但是实际上有许多不同的队列,对应了不同的执行优先级,例如 micro-task queue
macro-task queue
。micro-task queue
当中的任务就会被优先执行。Promise就是基于micro-task queue
实现的,而setTimeout
是基于macro-task queue
实现的。所以Promise的回调函数会比setTimeout
的回调函数先执行。具体不同优先级的Queue日后有机会再说说。