sunyongjian / blog

个人博客😝😋😄
664 stars 54 forks source link

我理解的 JS 运行机制及 Event Loop #38

Open sunyongjian opened 6 years ago

sunyongjian commented 6 years ago

基础篇

单线程的问题

JS 从诞生起就是一门单线程的语言。至于为什么是单线程,是因为作者认为 JS 是在浏览器执行的脚本语言,对它的要求不是很高,早期的网页对 JS 需求没那么高,都是轻量级的。而且写起来一定要简单,而多线程逻辑会造成交互、DOM 操作复杂。

但是单线程带来的问题就是,上一个任务没执行完,下一个任务就会一直等待。但在浏览器中,比如页面的某一部分需要从服务端获取数据展示,要发送 ajax 请求,http 通信是有延迟的,而且时间也不确定,假如一直等待数据返回,那线程就无法处理其他任务了,尽管它只是在等待而没有计算。像页面操作(点击)的代码就无法立即执行,只是 WebAPIs 把 callback 放到任务队列里而已,用户就会认为页面卡住了,操作无反应了。

另外,UI 渲染和 JS 执行是互斥的。虽然两者属于不同的线程,但是由于 JS 执行结果可能会对页面产生影响,所以浏览器对此做了处理,大部分情况下 JS 线程执行,render 线程就会暂停,当 JS 的同步代码执行完再去渲染。

伪代码:


fetch('url'); //假设是同步 fetch,阻塞代码执行。

render(); // 阻塞情况下无法执行

所以 JS 用异步任务 (asynchronous callback) 去解决这个问题。

异步与非阻塞

上面提到的这种执行模式算是阻塞执行,也就是在任务返回结果之前,JS 引擎线程 pending 了,导致后面的任务阻塞。对于浏览器这种实时性、交互性要求高的场景,肯定是不允许的。所以首先要非阻塞调用。非阻塞调用就是浏览器发送的这个请求任务,不需要等待结果就立即返回,但是具体数据回没回来,就需要 JS 线程不定时的去查看了。而更好的做法是,这个请求任务完成后(得到数据),通知我们,让我们去获取数据。这就需要用到异步 I/O 的模式。主线程执行遇到异步任务(请求),不需要等待结果返回,只需要代码块执行完毕,即去运行其他的任务。当异步任务完成后,会以某种方式“通知”主线程,假如主线程处于闲置,就会接收异步任务的结果,亦或是没有结果。在 JS 中,异步任务执行完都是以 callback 的形式,返回给主线程结果的。所以,JS 是这种异步非阻塞的方式,去满足页面实时交互的需求,避免“页面”假死的。

小补充

现代浏览器一个 tab 的线程包括不局限于:

JS 中的异步操作是通过 WebAPIs 去支持的,常见的有 XMLHttpRequest,setTimeout,事件回调(onclik, onscroll等)。而这几个 API 浏览器都提供了单独的线程去运行,所以才会有地方去做定时器的计时,request 的回调。

即当代码中出现这几个定义的异步任务,是浏览器提供能力去满足需求,是不跟 JS 引擎同属一个线程的,是浏览器实现了它们跟 JS 引擎的通信。

任务队列

上面提到了异步任务完成后,会通知主线程,以 callback 的方式获取结果或者执行回调。但是如果当前的主线程是忙碌的,异步任务的信号无法接收到怎么办呢。所以还需要一个地方保存这些 callback,也就是任务队列(task queue)。完整的描述下,就是主线程运行产生堆、栈,执行栈遇到异步任务(浏览器通常是调用 WebAPIs),不会等待,而是继续执行往下执行。而异步任务就会以各种方式,把 callback 加入任务队列中。待当前执行栈执行完毕,也就是出栈完毕,主线程就会从任务队列里读取第一个 callback,执行。同样的生成执行栈,结束后主线程如果发现任务队列中还有 callback,则会继续取出执行,如此重复操作。而这种循环的机制,就称之为事件循环(Event Loop)。

Event Loop

首先,主线程空闲时是需要不停的去任务队列查看有没有任务的,以保证异步任务的 callback 正常执行。

而更多的情况可能是任务队列有很多事件,主线程一旦空闲,也就是当前执行栈清空,便从任务队列读取事件。所以如果让我给 Event Loop 下定义的话,就是一种确保了这些异步任务的有序执行的机制。代码实现类似于:

while (queue.waitForMessage()) {
  queue.processMessage();
}

waitForMessage 就是从任务队列获取事件的方法,如果至少存在一个,则立即返回,否则,会一直等到事件添加进队列返回。当 waitForMessage 有返回,while 条件成立,则执行它的返回值,也就是 callback。

用图表示:

event-loop-1

执行栈(call stack)遇到异步方法 fetch,调用对应的 API 后,继续同步执行代码。setTimeout 也是异步方法,同样调用 API。此时是提供 API 的模块在执行,比如 XHR 等待请求,setTimeout 的计时。假如 setTimeout 是延迟 2100ms,请求用了 2000ms 返回,则 fetch callback 被添加到事件队列,主线程已经空闲,读取到事件,则入栈执行。另一边的 setTimeout callback 也加入到事件队列,当 fetch callback 执行完后,主线程继续从队列中取出并执行 setTimeout callback。

至此,应该是比较简洁的描述了 Event Loop 是怎么回事了。

代码练习

请求以及点击事件回调模拟成本较高,所以用 setTimeout 去写一些练习代码。

1.

setTimeout(() => {
  console.log(1);
}, 1000);

setTimeout(() => {
  console.log(2);
}, 0);

console.log(3);

JS 线程把此代码加入 call stack,遇到 setTimeout,视为异步任务并调用 WebAPIs 中的方法,delay 时间为 1000ms,定时器线程开始计时。call stack 继续执行,遇到第二个 setTimeout,同上,只是 0ms 延时,定时器线程把 callback 加入任务队列中。call stack 继续,console.log(1)。执行栈清空,JS 线程从任务队列中获取到 callback,取出加入栈中执行,清空,while 循环继续从任务队列获取。直到第一个计时器可能 ok 了,callback 加入任务队列,JS 线程获取到,执行。 结果为3, 2, 1. 2.

setTimeout(() => {
  console.log(4);
}, 300);

setTimeout(() => {
  console.log(3);
}, 200);

for (let i = 0; i < 10000; i++) {
  console.log(1);
}

setTimeout(() => {
  console.log(2);
}, 100);

call stack 上来遇到两个 setTimeout,定时器线程开始处理,只是两者 delay 时间不同。JS 线程开始同步的执行 for 循环,我的天,一万次,慢慢执行,不断地 console.log(1)。(这个地方时间长短由计算机的计算性能决定,不过大部分浏览器是需要个一两秒的。)在 JS 线程同步计算的同时,定时器线程依次把,200ms 和 300ms 的定时器 callback 加入任务队列。JS 线程 for 循环跑完,遇到 setTimeout,不堵塞,call stack 清空。任务队列中已经存在 callback,取出执行,call stack 清空,继续取出执行。之后最后一个定时器被加入任务队列,被 JS 线程执行。结果: 10000 x 1, 3, 4, 2

  1. 
    setTimeout(() => {
    console.log(1);
    setTimeout(() => {
    console.log(2);
    }, 100);
    }, 100);

setTimeout(() => { console.log(3); }, 200);

console.log(4);

JS 线程遇到 setTimeout,调完 WebAPI 继续,还是 setTimeout,继续往下,`console.log(4)`。100ms 的定时器结束,callback 加入任务队列,被 JS 线程取出执行,`console.log(1)`。遇到 setTimeout,由定时器线程处理,call stack 清空。此时定时器线程有两个 setTimeout,看 delay 时间一个是 200ms,一个是 100+100ms,但是由于 200ms 的先开始计时,经过输出 4,1 后,100ms 的才计时,所以肯定是 200ms 的先加入任务队列,先执行,则先输出 3,然后是 2。结果:`4, 1, 3, 2`。不过,当把第一个计时器改成
```js
setTimeout(() => {
  console.log(1);
  setTimeout(() => {
    console.log(2);
  }, 98); // 时间改成 98ms
}, 100);

结果就不确定了,取决于同步代码的计算时间,所以定时器这个东西还是比较坑的,delay 并不准确,取决于你的 JS 线程是否闲置以及执行效率。

点击去查看演示,能更好的理解

细节篇

宏任务、微任务

在任务队列这里,还有一个小的区分。异步任务分为宏任务(macro task)和微任务(micro task),并且会添加到不同的任务队列中。

tasks

不过查阅 html 规范中,并没有 macro task 的定义(我是看别人文章都这么写),为了严谨性,我们下面都称为 tasks。

一个事件循环中可能会有一个或多个 tasks,这个是根据 task 源划分的,比如事件(鼠标单击、键盘操作)就是一个 task 源,可能会放到一个任务队列中,XHR 的回调放到一个队列中,但是具体的优先级,这么多 tasks 到底先从哪个取,这个浏览器会根据情况去获取以达到更好的交互体验,先不放到本次研究范围,大概了解 tasks 会按照源去分成多个就好了。

常见的宏任务 tasks 包括:

所以我们之前的研究及代码示例都是 tasks 任务。

micro-task

microtask queue 在每个事件循环中只有一个,跟 tasks 区分,它的本意是尽可能早的执行异步任务。常见的 microtask 包括:

Promise.then 在不同的平台实现方式不同,不过大多数都参照 promise/A+ 规范,是当做 microtask 处理的。

microtask 是在一个事件循环结束后,立即执行,即 tasks 的一个任务执行后,并且会清空 microtask 队列。另外,如果 microtask 中新添加了 microtask,会放到 queue 末尾一起执行。

举个栗子:

// 1. 加入 tasks 队列
setTimeout(()=>{
  // 7. 首次 eventloop 结束,从 tasks 中取出 setTimeout callback,执行。
  console.log('timer');
  // 8. 加入 microtask 中
  Promise.resolve().then(function() {
    console.log('promise1')
  })
  // 9. task 执行完,清空 micro 队列,输出 'promise1'
}, 0);

// 2. 加入 microtask 队列
Promise.resolve().then(function() {// 5. 第一个 microtask 任务
  console.log('promise2');
  // 6. 把 promise3 加入 micro 队列,发现队列不为空,执行输出 'promise3'
  Promise.resolve().then(function() {
    console.log('promise3');
  })
})

// 3. 执行,输出 'script'
console.log('script');
// 4. 第一个 eventloop task 阶段完毕, 开始执行 microtask queue

其他阶段

Node 篇

Node 中的事件循环

参考

视频 Philip Roberts: 什么是JavaScript中的事件循环 (Event Loop)

深入探究 eventloop 与浏览器渲染的时序问题

humorHan commented 5 years ago

代码练习这里有笔误 JS 线程把此代码加入 call stack,遇到 setTimeout,视为异步任务并调用 WebAPIs 中的方法,delay 时间为 100ms, 看demo应该是1000ms

整体写的很不错~👍