一文打通事件循环的任督二脉

一、前言

事件循环(Event Loop)对于前端秃头选手来说,它是及其重要的基础知识。在平时的讨论或者面试中也是一个非常高频的话题。Event Loop 即事件循环,是指浏览器或NodeJS的一种解决javaScript单线程运行时不会阻塞的一种机制也就是我们经常使用异步的原理。 这篇文章的作用主要就是打通事件循环的任督二脉
复制代码

二、概述

理解JavaScript的事件循环往往伴随执行栈、任务队列(宏任务、微任务)、JavaScript单线程执行过程以及浏览器异步机制等相关问题。而浏览器和Node中的事件循环实现也有很大的差别。熟悉事件循环了解浏览器运行将对我们理解JavaScript的执行过程和排查运行问题有很大帮助。
复制代码

栈以及执行栈

栈(Stack)在计算机科学中是限定仅在表尾进行插入或删除操作的线性表。 栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。
栈的特性是先进后出。
复制代码

stack.gif Javascript有一个 main thread 主线程和 call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。

队列以及任务队列

队列(Queue)也是一种操作受限制的线性表。特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作
队列是特性是先进先出
复制代码

queue.gif JS 在解析一段代码时当遇到异步任务时就交给其他线程处理,而等异步任务完成后,将回调放入任务队列中待执行栈来取出执行。

任务分为宏任务和微任务(常见的)

image.png image.png

三、浏览器中 JavaScript 异步执行的原理

JS是单线程,也就是同一时刻只能执行一个任务,那么为什么浏览器可以同时执行异步任务呐?

因为浏览器是多线程的,当JS在浏览器中需要执行异步任务时,浏览器会启动另外一个线程。JS是单线程的是因为 浏览器只提供一个JS引擎线程(主线程)来跑JS代码。但是浏览器中还有线程比如 定时器线程和 HTTP 请求线程等等,这些线程主要不是来跑 JS 代码的。

比如主线程需要发一个axios请求,就会把这个任务交给另外一个浏览器线程(HTTP 请求线程)去真正发送请求,待请求回来了,再将回调函数(callback)里需要执行的 JS 回调交给 JS 引擎线程去执行

即浏览器才是真正执行发送请求这个任务的角色,而 JS 只是负责执行最后的回调处理。所以这里的异步不是 JS 自身实现的,其实是浏览器为其提供的能力。
复制代码

image.png

浏览器中不仅有多个线程,还有多个进程:如渲染进程、GPU 进程和插件进程等作为前端开发者,主要重点关注其渲染进程,渲染进程下包含了 JS 引擎线程、HTTP 请求线程和定时器线程等等,这些线程为 JS 在浏览器中完成异步任务提供了基础。

四、浏览器中的事件循环

JS 引擎线程在跑JS代码时,会将同步代码按顺序排队在执行栈,然后依次执行里面的函数。当遇到异步任务时就交给其他线程处理,待当前执行栈所有同步代码执行完成后,会从任务队列中去取出已完成的异步任务的回调加入执行栈继续执行,遇到异步任务时再次交给其他线程。.....,如此循环往复。而其他异步任务完成后,将回调放入任务队列中待执行栈来取出执行。

JS 按顺序执行执行栈中的方法,每次执行一个方法时,会为这个方法生成独有的执行环境(上下文 context),待这个方法执行完成后,销毁当前的执行环境,并从栈中弹出此方法(即消费完成),然后继续下一个方法。

可见,在事件驱动的模式下,至少包含一个执行循环来检测任务队列是否有新的任务。通过不断循环去取出异步回调来执行,这个过程就是事件循环,而每一次循环就是一个事件周期或称之为一次 tick。

事件循环的过程中,执行栈在同步代码执行完成后,优先检查微任务队列是否有任务需要执行,如果没有,再去宏任务队列检查是否有任务执行,如此往复。微任务一般在当前循环就会优先执行,而宏任务会等到下一次循环,因此,微任务一般比宏任务先执行,并且微任务队列只有一个,宏任务队列可能有多个。另外我们常见的点击和键盘等事件也是属于宏任务。

eventLoop.png

也可以这么去理解:微任务是在当前事件循环的尾部去执行;宏任务是在下一次事件循环的开始去执行。

扫描二维码关注公众号,回复: 13701310 查看本文章

微任务和宏任务的本质区别

JS 遇到异步任务时会将此任务交给其他线程去处理,自己的主线程继续往后执行同步任务。比如 setTimeout 的计时会由浏览器的定时器线程来处理,待计时结束,就将定时器回调任务放入任务队列等待主线程来取出执行。因为 JS 是单线程执行的,所以要执行异步任务,就需要浏览器其他线程来辅助。

当执行到 promise.then 时,V8 引擎不会将异步任务交给浏览器其他线程,而是将回调存在自己的一个队列中,待当前执行栈执行完成后,立马去执行 promise.then 存放的队列,promise.then 微任务没有多线程参与,甚至从某些角度说,微任务都不能完全算是异步,它只是将书写时的代码修改了执行顺序而已。

setTimeout 有 “定时等待” 这个任务需要定时器线程执行;ajax 请求有 “发送请求” 这个任务需要 HTTP 线程执行,promise.then 它没有任何异步任务需要其他线程执行,它只有回调,即使有,也只是内部嵌套的另一个宏任务。
复制代码
  • 宏任务特征:  有明确的异步任务需要执行和回调;需要其他异步线程支持。
  • 微任务特征:  没有明确的异步任务需要执行,只有回调;不需要其他异步线程支持。

事件循环小测试1

console.log('start')
async function async1() {
    console.log('a')
    await async2()
    console.log('b')
}
async function async2() {
    console.log('c')
}
async1()

setTimeout(function() {
    console.log('d')
}, 0)

new Promise(resolve => {
        console.log('e')
        resolve()
    })
    .then(function() {
        console.log('f')
    })
    .then(function() {
        console.log('g')
    })
console.log('end')
// 打印的结果为:start->a->c->e->end->b->f->g->d
复制代码
执行的顺序分析
  1. 执行代码,输出start
  2. 执行async1(),先输出a会调用async2(),然后输出c,此时将会保留async1函数的上下文,然后跳出async1函数(这个可以理解为:await后如果是变量或者没有回调函数,会将await后面的代码注册为一个微任务)产生第一个微任务 下面例子对比使用 记住下
  3. 遇到setTimeOut ,产生一个宏任务
  4. 执行Promise,输出e,遇到then,产生第二个微任务
  5. 继续执行代码 输出end
  6. 代码逻辑执行完毕(当前宏任务执行完毕),开始执行宏任务产生的微任务,执行async1()剩下的代码输出b
  7. 按顺序执行下一个微任务输出f 产生一个新的微任务 并且执行输出g
  8. 打印为什么会有一个undefined 执行await就会产生一个promise返回
let promise_ = new Promise((resolve,reject){ resolve(undefined)})
复制代码
  1. 最后,执行下一个宏任务,即执行setTimeout,输出d

事件循环小测试2(注意await后的回调函数注册为微任务的时机)

console.log('start')
async function async1() {
    console.log('a')
    await async2()
    console.log('b')
}
async function async2() {
    return Promise.resolve().then(()=>{ 
         console.log('c')
    })
}
async1()

setTimeout(function() {
    console.log('d')
}, 0)

new Promise(resolve => {
        console.log('e')
        resolve()
    })
    .then(function() {
        console.log('f')
    })
    .then(function() {
        console.log('g')
    })
console.log('end')
// 打印的结果为:start->a->e->end->c->f->g->b->d
复制代码

需要注意的是: await后如果是回调函数会生成一个微任务但是 awit并不先把await后面的代码注册到微任务队列中去,而是执行完await之后,直接跳出async1函数,执行其他代码 其他代码执行完毕后,需要回到async1函数去执行剩下的代码,然后把await后面的代码注册到微任务队列当中

定时器的误差

此时再来理解定时器的误差就是张飞吃豆芽--小菜一碟
事件循环中,总是先执行同步代码后,才会去任务队列中取出异步回调来执行。当执行 setTimeout 时,浏览器启动新的定时器线程去计时,计时结束后触发定时器事件将回调存入宏任务队列等待 JS 主线程来取出执行,如果这时主线程还在执行同步任务的过程中,那么此时的宏任务就只有先挂起,从而造成计时器不准确的问题,同步代码耗时越长,计时器的误差就越大。不仅同步代码,由于微任务会优先执行,所以微任务也会影响计时,假设同步代码中有一个死循环或者微任务中递归不断在启动其他微任务,那么宏任务里面的代码可能永远得不到执行。
复制代码

视图渲染更新

微任务队列执行完成后,也就是一次事件循环结束后,浏览器会执行视图渲染,当然这里会有浏览器的优化,可能会合并多次循环的结果做一次视图重绘,因此视图更新是在事件循环之后,所以并不是每一次操作 Dom 都一定会立马刷新视图。视图重绘之前会先执行 requestAnimationFrame 回调
复制代码

五、NodeJS中的事件循环

JS引擎本身不是先事件循环的机制,这个是由它的宿主(浏览器)实现的,而在NodeJS中也有自己的事件循环实现。NodeJS中也是 循环+任务队列的流程以及微任务优先于宏任务,大致表现于浏览器是一致, 也存在一些差异,其添加了一些任务类型和任务阶段。
复制代码

NodeJS中的异步方法

因为都是基于V8引擎,浏览器中包含的异步任何和NodeJS中的也是一样。另外 NodeJS 中还有一些其他常见异步形式:
- 文件 I/O: 异步加载本地文件
- setImmediate():与 setTimeout 设置 0ms 类似,在某些同步任务完成后立马执行
- process.nextTick():在某些同步任务完成后立马执行
- server.close、socket.on('close', ...)等: 关闭回调
复制代码

事件循环模型

NodeJS 的跨平台能力和事件循环机制都是基于 Libuv 库实现的。NodeJS 中 V8 引擎将 JS 代码解析后调用 Node API,然后 Node API 将任务交给 Libuv 去分配,最后再将执行结果返回给 V8 引擎。在 Libux 中实现了一套事件循环流程来管理这些任务的执行,所以 NodeJS 的事件循环主要是在 Libuv 中完成的

image.png

事件循环各阶段

在 NodeJS 中 JS 的执行,我们主要需要关心的过程分为以下几个阶段,下面每个阶段都有自己单独的任务队列,当执行到对应阶段时,就判断当前阶段的任务队列是否有需要处理的任务。
复制代码
  • timers 阶段
执行所有 setTimeout() 和 setInterval() 的回调
复制代码
  • pending callbacks 阶段
某些系统操作的回调,如 TCP 链接错误。除了 timers、close、setImmediate 的其他大部分回调在此阶段执行(上一轮循环中少数的callback会放在这一阶段执行。)
复制代码
  • poll 阶段
轮询等待新的链接和请求等事件,执行 I/O 回调等。V8 引擎将 JS 代码解析并传入 Libuv 引擎后首先进入此阶段。如果此阶段任务队列已经执行完了,则进入 check 阶段执行 setImmediate 回调(如果有 setImmediate),或等待新的任务进来(如果没有 setImmediate)。在等待新的任务时,如果有 timers 计时到期,则会直接进入 timers 阶段。此阶段可能会阻塞等待。
复制代码
  • check 阶段
setImmediate 回调函数执行(setImmediate()是将事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行setImmediate指定的回调函数)的callback
复制代码
  • close callbacks 阶段
关闭回调执行,如 socket.on('close', ...)
复制代码
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │ idle, prepare (内部使用)  │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

复制代码

上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段。这里也是与浏览器中逻辑差异较大的地方,不过浏览器不用区分这些阶段,也少了很多异步操作类型,所以不用刻意去区分两者区别

NodeJS事件循环输出测试

const fs = require('fs');
fs.readFile(__filename, (data) => {
  	// poll(I/O 回调) 阶段
    console.log('readFile')
    Promise.resolve().then(() => {
        console.error('promise1')
    })
    Promise.resolve().then(() => {
        console.error('promise2')
    })
});
setTimeout(() => {
  	// timers 阶段
    console.log('timeout');
    Promise.resolve().then(() => {
        console.error('promise3')
    })
    Promise.resolve().then(() => {
        console.error('promise4')
    })
}, 0);
// 下面代码只是为了同步阻塞1秒钟,确保上面的异步任务已经准备好了
var startTime = new Date().getTime();
var endTime = startTime;
while(endTime - startTime < 1000) {
    endTime = new Date().getTime();
}
// 最终输出 timeout promise3 promise4 readFile promise1 promise2
复制代码

与浏览器的差异还体现在同一个阶段里的不同任务执行 (NodeJS 10之前存在 现已和浏览器一致就不再讨论)

nextTick、setImmediate 和 setTimeout

实际项目中我们常用 Promise 或者 setTimeout 来做一些需要延时的任务,比如一些耗时计算或者日志上传等,目的是不希望它的执行占用主线程的时间或者需要依赖整个同步代码执行完成后的结果。
NodeJS 中的 process.nextTick() 和 setImmediate() 也有类似效果。其中 setImmediate()是在 check 阶段执行的,而 process.nextTick() 的执行时机不太一样,它比 promise.then() 的执行还早,在同步任务之后,其他所有异步任务之前,会优先执行 nextTick可以想象是把 nextTick 的任务放到了当前循环的后面,与 promise.then() 类似,但比 promise.then() 更前面。
复制代码
let bar;
setTimeout(() => {
  console.log('setTimeout');
}, 0)
setImmediate(() => {
  console.log('setImmediate');
})
function someAsyncApiCall(callback) {
  process.nextTick(callback);
}
someAsyncApiCall(() => {
  console.log('bar', bar); 
});
bar = 1;
// 会打印1
复制代码

setImmediate 和 setTimeout的区别

setImmediatesetTimeout()是相似的,但根据它们被调用的时间以不同的方式表现。

  • setImmediate()设计用于在当前poll阶段完成后check阶段执行脚本 。
  • setTimeout() 安排在经过最小(ms)后运行的脚本,在timers阶段执行。
举个例子
setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('immediate');
});
// 输出:timeout、 setImmediate
复制代码

第一轮循环后,分别将 setTimeout 和 setImmediate 加入了各自阶段的任务队列。第二轮循环首先进入 timers 阶段,执行定时器队列回调,然后 pending callbackspoll 阶段没有任务,因此进入 check 阶段执行 setImmediate 回调。所以最后输出为timeout、 setImmediate

执行定时器的顺序将根据调用它们的上下文而有所不同。 如果从主模块中调用两者,那么时间将受到进程性能的限制。其结果也不一致

如果在I / O周期内移动两个调用,则始终首先执行立即回调:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
复制代码

其结果是immediate => timeout
主要原因是在I/O阶段读取文件后,事件循环会先进入poll阶段,发现有setImmediate需要执行,会立即进入check阶段执行setImmediate的回调。 然后再进入timers阶段,执行setTimeout,打印timeout

最后文章内容参考了

一次弄懂Event Loop(彻底解决此类面试问题)

事件循环eventloop)

猜你喜欢

转载自juejin.im/post/7075524203184652296