JavaScript如何实现异步
既然js是单线程语言,单线程意味着JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待,也就是说一段js代码中,必须从第一行按顺序执行到最后一行。
那么如何在单线程中实现异步呢?
调用栈 Call Stack
了解异步之前,需要先知道正常的同步机制是什么样子的。
调用栈是一种栈结构
调用栈是JS引擎执行程序的一种机制。程序每调用一层函数(方法),引擎就会生成它的栈帧,栈帧里面保存了函数的执行上下文,然后将它压入调用栈。栈是一个后进先出的结构,直到最里层的函数执行完,引擎才开始将最后加入的栈帧从栈中弹出。
简单来说,调用栈就是用来保存程序执行的上下文和执行代码的。为什么需要这个东西,而不是直接去执行呢,个人理解是因为代码中会有嵌套结构调用来调用去的,使用栈结构就可以保存这些调用记录,一层层的调用和返回。
以一个同步任务为例:
const foo = () => {
console.log(1)
console.log(2)
}
foo()
console.log(3)
在这个例子中,控制台打印123。调用栈的入栈出栈顺序如下:
foo
-> 入栈
console.log(1)
-> 入栈
console.log(1)
<- 出栈
console.log(2)
-> 入栈
console.log(2)
<- 出栈
foo
<- 出栈
console.log(3)
-> 入栈
console.log(3)
<- 出栈
在这个同步任务执行过程中,我们可以看到该结构是按顺序一行一行压栈执行的,下面的代码要等前面的代码全部执行完毕并弹栈了才能执行。在这套机制的基础上如果想实现异步,就要需要解决两个问题:
1.区分出同步任务和异步任务。
2.发现异步任务时,进行另一套异步处理机制,继续执行下面的代码,避免阻塞。
事件循环与任务队列
JS设计了一个任务队列和事件循环来实现异步过程。
异步思路简单来说,就是我们通过判断api的类型进行分类处理。如果是同步的api,我们就按照上述的流程同步执行。如果发现异步api(包括定时器、网络请求等等),我们直接交给异步线程来处理。主线程不受干扰,继续执行代码避免阻塞。那么异步线程处理完了的回调又该在何时执行呢?这里我们使用一个队列,当异步线程处理完的回调存储在队列中,当主线程中的调用栈执行完了,也就是那些同步任务执行完了,再过来执行队列里的异步回调。流程如下:
1.主线程执行
2.代码在压入调入栈之前会进行判断,如果是同步API则正常入栈执行。如果是异步API(如定时器)则交给异步的线程去处理(如定时器线程在后台执行),然后调入栈继续压栈下一行代码。
3.异步线程执行完成后,事件触发线程将异步的回调放入任务队列中。(事件触发线程是JS引擎中的一个线程)
4.主线程的调用栈执行完了,此时主线程会去任务队列中看看有没有任务。
5.主线程如果发现任务队列中有任务,就按上述继续压栈执行。
6.不断重复上述过程,直至代码执行完成。
-
存储异步执行任务的回调的一个队列称为任务队列。
-
每次调用栈被清空后,都会在消息队列中读取新的任务到调用栈中,如果没有新的任务,就会等待,直到到有新的任务。这样循环往复的过程就叫事件循环。
setTimeout(function(){
console.log(0);
},0)
console.log(1);
按照这个异步思路,上述代码的输出为10,而非01也就可以理解。只要你是个异步的api,那么一定是在同步api后面执行的,和异步处理所花费的时间无关。
宏任务和微任务
宏任务和微任务是任务队列中的两种任务。为什么要把任务队列中的任务进行分类呢?
这种设计是为了给异步任务分一个高低贵贱,给紧急任务一个插队的机会,否则新入队的任务永远被放在队尾。而微任务的优先度永远比宏任务更高。调用栈从任务队列压栈时,会优先入栈微任务。
宏任务(macrotask):
- 异步 Ajax 请求
- setTimeout
- setInterval
- 文件操作
- 其它宏任务
微任务(microtask):
- Promise.then
- catch 和 finally
- process.nextTick
- 其它微任务
个人理解
即使js代码通过一系列方法实现了异步操作,但有些机制仍然是要靠多线程来支持的。我个人的理解,就算有任务队列和事件循环,也不可能靠一个单线程的方式实现异步操作,你总归还是需要另外一个线程来执行或者回调这个异步操作,也就是定时器线程、事件触发线程等等。