JS的异步与事件循环探微

JavaScript起源

1994年,网景公司(Netscape)发布了Navigator浏览器0.9版。这是历史上第一个比较成熟的网络浏览器,轰动一时。但是,这个版本的浏览器只能用来浏览,不具备与访问者互动的能力。

网景公司急需一种网页脚本语言,使得浏览器可以与网页互动。1995年JavaScript诞生了,起初名字为Livescript,但是后来网景与Sun公司成立了一个开发联盟。Sun公司那时大肆宣传Java这种语言可以”一次编写,到处运行”(Write Once, Run Anywhere),后面LiveScript改名为JavaScript,本质上来说JavaScript和Java没什么关系(单纯蹭热度)。

如今,JavaScript的用途而是具备了与浏览器窗口及其内容等几乎所有方面交互的能力,已经成为一门功能全面的编程语言。

同步与异步,阻塞与非阻塞

计算机领域中的同步(Synchronous)和异步(Asynchronous)和我们生活中的同步和异步的概念是不太一样。生活中的同步,突出的是‘同’,相同的步伐,是咱俩一起行动,比如一起去逛街吃饭饭睡觉觉。异步则是你忙你的,我忙我的,步调不致且互不干扰。到计算机里的同步和异步则关注的是消息通知机制。

计算机领域所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。
换句话说,就是由调用者主动等待这个调用的结果。

而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

打个比如:以打电话为例,你给小明打电话,问他借本书,如果是同步机制,小明就会说,我看一下哈,不知道有没有,(可能是5秒,也可能是一天)告诉你结果(返回结果)。而异步通信机制,小明直接告诉你我查一下啊,看看我的书柜里面有没有,查好了打电话给你,然后直接挂电话了(不返回结果)。然后查好了,他会主动打电话给你。在这里小明通过“回电”这种方式来回调。

这里需要与阻塞非阻塞做一下区分,阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.

阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

还是上面的例子,你打电话问小明有书借没,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果,如果是非阻塞式调用,你不管小明有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下小明有没有返回结果。在这里阻塞与非阻塞与是否同步异步无关。跟小明通过什么方式回答你结果无关。

JavaScript Engine(Js 引擎)

js 引擎即 js 虚拟机,主要是负责解析和执行 js 的,它是浏览器所实现的,不同的浏览器有不同的实现方式「采用 c/c++ 实现」,这里以比较流行V8引擎为例来说明
该引擎包括两个主要组件:

  • Memory Heap 内存堆 ——  这是内存分配发生的地方

  • Call Stack 调用堆栈 ——  这是在你代码执行时栈帧存放的位置(js 是单线程说的就是 call stack)

作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程。
浏览器有渲染引擎和 js引擎,浏览器是从上向下解析 html 标签的,当遇到 script 标签(js 代码)时会立即停止解析,直接执行 js 脚本,所以渲染引擎和 js 引擎是互斥的(js 操作 DOM 的会影响渲染),这一个过程是同步的,所以加载一个耗时的 js 会导致界面卡死的,影响用户体验。

另外假定js同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,Js就是单线程,这已经成这门语言的核心特征,将来也不会改变。

注:webworker支持多线程,但是不能访问DOM。

Call Stack(调用栈)

先上代码

1
2
3
4
5
6
7
8
function multiply(x, y) {
return x * y;
}
function printSquare(x) {
var s = multiply(x, x);
console.log(s);
}
printSquare(5);

当引擎开始执行这个代码时,call stack将会变成空的。之后,执行的步骤如下:

call stack的每个入口被称为 stack frame(栈帧)。顾名思义,栈即遵循先进先出原则,当一个方法调用的时候就入栈,执行完成以后就出栈。

Web APIs

看完前面的铺垫你是否会产生这些疑问,由于 js 引擎中的 call stack同一时间只能干一件事情,那么他是如何是实现异步操作的?
因为Js引擎只能自上而下执行,万一上一行解析时间很长,调用者一直等待着结果的返回,那么下面的代码不就被阻塞了么。

答案是虽然 js 是单线程的,但浏览器却是多线程的,我们知道 js 有好多 API 有些不是核心 js 语言的一部分,比如 BOM DOM AJAX setTimeOut Canvas WegGl 等 api 浏览器可以在调用之外执行这些 api(另起一个或多个线程跑这些 api)

这些 api 就可以独立于调用栈call stack来执行自己的功能,但是有一个问题是如果这些 api 执行完以后该怎么办呢?有两种方案

  1. 我们将 web api 完成的方法直接推送到调用栈call stack
  2. 我们采取一些机制来保存这些响应,在合适的时候推送给调用栈

第 1 种方法显然不靠谱,如果 web api 执行完以后直接把结果给调用栈可以会影响正在执行的调用栈,所以浏览器采用第二种方法,使用消息队列来保存这些 web api 执行的响应以便在调用栈可以调用的时候推送给调用栈,这个保存消息的东西就是接下来我们要说的 Message Queue

Message Queue(回调队列)

Message Queue(消息队列也叫 Callback Queue)是用来保存 Web Api 调用完成以后的所有消息的回调函数,当call stack为空时(也就是调用栈中的方法执行完毕以后)Message Queue 中的回调方法(先进先出)会被添加到call stack 中去执行,但是浏览器是什么方式来把调用栈和 message Queue 联系起来的「什么机制把 Message Queue 中的回调方法给 call stack 当 call stack 为空的时候」,它就是 Event Loop

Event Loop(事件循环)

Event Loop 是把 call stack 和 Message Queue 联系起来的纽带和桥梁,Event Loop 是一个基于事件的并发模型,它时刻在监听着消息队列,如果有完成的消息它此刻还要关心 call stack 是否为空,如果为空则把 Messag Queue 中的回调结果推送给 call statck 回调方法执行
Event Loop 做两件事情
1、监听 Message Queue(是否有消息)
2、监听 call statck (看是否为空,如果为空则推送结果)

经典问题:

1
2
3
4
5
6
 console.log('1')
setTimeout(function(){
console.log('2')
}, 0)
console.log('3')
// 1,3,2

我们来分析一下,当上面的代码加载在浏览器中步骤是什么样子:

  1. console.log(‘1’)被推到调用栈中然后当结束时从栈中被移除。
  2. 接下来setTimeOut( )函数被调用,所以它被推到了栈顶。setTimeOut( )有两个参数:一是回调,二是毫秒数。
  3. 浏览器单独开一个线程去执行这个setTimeOut( )方法在web APIs环境中开始了一个0秒的计时器此时,setTimeOut( )执行完毕并被移出栈。
  4. console.log(‘3’)被推到栈里,执行完后被移除。
  5. web api 执行 setTimeout 方法完毕,将结果给 Message Queue ,此是 web api 就变成空的,
    栈也已经变成空的,Event Loop 监听着 Message Queue。
  6. Event loop 把 Message Queue 中的方法取出来,推给空的调用栈.
  7. 执行其中的方法体 console.log(‘2’), 执行完毕 出栈,调用栈变为空

宏任务与微任务

以上的Event Loop过程是一个宏观的表述,实际上因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

以下事件属于宏任务:

  • setInterval()
  • setTimeout()
  • setImmediate()
  • I/O

以下事件属于微任务:

  • process.nextTick()
  • new Promise()(有些实现的promise 将 then 方法放到了宏任务中,浏览器默认放到了微任务)
  • Object.observe (已废弃)
  • MutationObserver(不兼容,已废弃)
  • MessageChannel(vue中 nextClick 实现原理)

前面我们介绍过,在一个Event Loop中,异步事件返回结果后会被放到一个Message Queue中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。

我们只需记住当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次Event Loop中,微任务永远在宏任务之前执行。

这样就能解释下面这段代码的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(function () {
console.log(1);
});

new Promise(function(resolve,reject){
console.log(2)
resolve(3)
}).then(function(val){
console.log(val);
})
# 2
# 3
# 1

总结

到此我们把 js 非阻塞和异步,Call back 与Event Loop原理有所了解,现在总结一下

  1. 同步指的是发起调用后,调用方主动等待调用结果;异步指的是,发起调用后,调用者没有立刻得到返回结果,后续可通过回调等方式获取其结果。
  2. 阻塞关注的是等待调用结果时的状态,若线程挂起,等待结果返回就是阻塞.若调用在不能立刻得到结果之前,该调用不会阻塞当前线程会立刻返回则是非阻塞
  3. js 是非阻塞异步的单线程(单线程指的就是 Call stack)
  4. js 实现异步的方式是基于 Event Loop 的并发模型
  5. 浏览器的 web api 不是 js 核心的部分,但是和 call stack 不冲突执行(浏览器另外开线程去执)
  6. web api 的执行结果不能直接给 call stack 先要通过 Message Queu 把结果存起来,等待 Event Loop 去处理
  7. Event Loop 如果发现 call statck 为空时「此时就是推入 Message Queue 中的消息的最佳时机」取出消息队列中的消息推入给调用栈,异步结束
  8. Message Queue分两种宏任务消息队列,微任务消息队列,同一次Event Loop中,微任务永远在宏任务之前执行

参考