说说事件循环(浏览器和Node)

781 阅读9分钟

浏览器的事件循环

为什么有事件循环

浏览器的每个渲染进程都有一个主线程,并且主进程非常的忙,既要处理DOM,又要计算样式,还要处理布局,还需要处理js任务以及各类输入事件。如何让这些不同类型的任务有条不紊的运行呢?这就需要一个消息队列和事件循环来解决。

单线程

因为js是单线程的,当我们执行一段js代码的时候,我们按照顺序把所有的任务放进主线程中,任务按照顺序在线程中依次被执行,执行完毕后,线程会自动退出,如下图所示:

事件循环和消息队列

但是并不是所有的任务都是在执行之前就统一安排好的,在线程运行过程中,接收到了新的任务(比如用户的输入等),就需要用到事件循环机制,可以简单理解为线程循环等待事件的发生。
在实际应用中渲染主线程会频繁收到来自IO的任务,比如响应资源加载完成事件、响应点击事件等,如何保证执行顺序呢?通用模式就是使用消息队列。消息队列是一个“先进先出”的队列结构, 根据以上的改造我们可以看出,主线程执行的任务全部从消息队列中获取,如果有其他线程有任务需要在主线程中执行,只需要将任务添加到该消息队列中就可以了。

微任务和宏任务

为什么要引入微任务,只有一种类型的任务不行么?

队列的“先进先出”的属性决定了消息队列中的任务,必须等待前面的任务被执行完毕才会被执行。鉴于此,有如下问题需要解决:
如何权衡效率和实时性呢?
比如这个问题:Dom节点变化的时候根据变化来处理相应逻辑,一个通用的设计是:利用js设计一套监听接口,当变化发生时,调用这些接口。这个设计存在问题,如果Dom变化非常频繁,那么每次变化都会调用相应接口,那么当前任务的执行时间会被拉长,从而导致执行效率下降。如果采用异步的消息事件放到消息队列的尾部,又会影响到实时性
针对如上情况,微任务应势而生!通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列。微任务是高优先级的任务类型。
总结就是:执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。

宏任务

页面中的大部分任务都是在主线程上执行的,这些任务包括:

  • 渲染事件(解析DOM、计算布局、绘制)
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等)
  • JavaScript脚本执行事件
  • 网络请求完成、文件读写完成事件
  • setTimeout、setInterval

微任务

  • MutationObserver
  • Promise
  • JavaScript脚本执行事件
  • 网络请求完成、文件读写完成事件
  • setTimeout、setInterval

执行顺序

  1. 在主线程上添加宏任务与微任务
    • 执行顺序:线程 => 主线程上创建的微任务 => 主线程上创建的宏任务
  2. 在微任务中创建微任务
    • 执行顺序:主线程 => 主线程上创建的微任务1 => 微任务1上创建的微任务2 => 主线程上创建的宏任务
  3. 宏任务中创建微任务
    • 执行顺序:主线程 => 主线程上的宏任务队列1 => 宏任务队列1中创建的微任务
  4. 微任务队列中创建的宏任务
    • 执行顺序:主线程 => 主线程上创建的微任务 => 主线程上创建的宏任务 => 微任务中创建的宏任务
  5. async/await:分两种情况
    • 如果await 后面直接跟的为一个变量,比如:await 1;这种情况的话相当于直接把await后面的代码注册为一个微任务,可以简单理解为promise.then(await下面的代码)。然后跳出async1函数,执行其他代码。
    • 如果await后面跟的是一个异步函数的调用。此时执行完awit并不先把await后面的代码注册到微任务队列中去,而是执行完await之后,直接跳出async1函数,执行其他代码。其他代码执行完毕后,需要回到async1函数去执行剩下的代码,然后把await后面的代码注册到微任务队列当中,注意此时微任务队列中是有之前注册的微任务的。
//情况1
console.log('script start')

async function async1() {
	await async2()
	console.log('async1 end')
}
async function async2() {
	console.log('async2 end')
}
async1()

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

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
.then(function() {
	console.log('promise1')
})
.then(function() {
	console.log('promise2')
})

console.log('script end')
//输出结果为:script start、async2 end、Promise、script end、async1 end、promise1、promise2、setTimeout
// 情况2
console.log('script start')

async function async1() {
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 end')
    return Promise.resolve().then(()=>{
        console.log('async2 end1')
    })
}
async1()

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

new Promise(resolve => {
    console.log('Promise')
    resolve()
})
.then(function() {
    console.log('promise1')
})
.then(function() {
    console.log('promise2')
})

console.log('script end')
//输出结果为:script start、async2 end、Promise、script end、async2 end1、promise1、promise2、async1 end、setTimeout

Node中的事件循环

node.js是单线程的,异步不阻塞且高并发的一门语言。

为什么有事件循环

让我们来看一下现在的服务器端语言中存在着什么问题?在Java、php或ASP.NET等服务器端语言中,为每一个客户端连接创建一个新的线程,而每个线程需要耗费大约2MB的内存,也就是说,理论上,具有8GB内存的服务器可以同时连接的最大用户数大约为4000个左右,如果需要支持更多用户就需要增加服务器数量。而Node.js修改了客户端到服务端的连接方法,它并不为每个客户端连接创建新线程,而是为每个客户端连接触发一个在node.js内部进行处理的事件。
Node.js中采用了两种机制:

  • 非阻塞型I/O(将返回结果放到回调函数中执行,不会阻碍其他处理的进行)
  • 事件环(一个时刻只能执行一个事件回调函数,但是执行中途可以转而处理其他事件),然后返回执行原事件回调函数

解析事件循环机制

先来看一张官方的图片,每个框被称为事件循环机制的一个阶段。

阶段概述

  • timers:本阶段执行已经被 setTimeout() 和 setInterval() 的调度回调函数。
    • 指定可以执行所提供回调的时间的阙值,而不是希望其执行的确切时间。只能说是尽早执行。具体何时执行由poll阶段控制。
  • pending callbacks: 执行延迟到下一个循环迭代的I/O回调
  • idle,prepare:仅系统内部使用
  • poll:轮询。检索新的 I/O 事件;执行与 I/O 相关的回调:网络连接,数据获取,读取文件等操作。有两个重要功能:1. 计算应该阻塞和轮询I/O的时间; 2. 处理轮询队列中的事件;
    • 如果轮询队列不是空的,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
    • 如果轮询队列是空的:
      • 轮询队列为空,则检查是否有达到阙值的计时器,如果有计时器已准备就续,事件循环将回到timer阶段执行计时器回调
      • 如果脚本被setImmediate调度,则进入到check阶段执行脚本
      • 如果未被setImmediate调度,则等待回调被添加到队列中,然后执行。
  • check:setImmediate回调函数在这里执行
  • close callbacks:关闭的回调函数

process.nextTick

我们可以看到在上图中并没有显示process.nextTick,这是因为从技术上讲process.nextTick不是循环的一部分。Node 执行完所有同步任务,接下来就会执行process.nextTick的任务队列。开发过程中如果想让异步任务尽可能快地执行,可以使用process.nextTick来完成。

执行顺序

对比setImmediate和setTimeout

  • setImmediate() 设计为一旦在当前轮询阶段完成,就执行脚本。
  • setTimeout() 在最小阈值(ms 单位)过后运行脚本。 如果两者都在主模块中调用,则收到进程细嫩感的约束,比如下面代码的输出结果是不确定的。因为受到进程性能的约束。
setTimeout(() => { console.log('timeout'); }, 0);
setImmediate(() => { console.log('immediate'); });

但是如果放在一个I/O循环中调用,那么setImmediate总是被优先调用,如下:

const fs = require('fs');
fs.readFile(__filename,()=>{
setTimeout(() => { console.log('timeout'); }, 0);
setImmediate(() => { console.log('immediate'); });
})
// 输出:immediate、timeout

代码分析

Node开始执行脚本时,会先进行事件循环的初始化,但是这时事件循环还没有开始,会先完成下面的事情。

  1. 同步任务 发出异步请求 规划定时器生效的时间 执行process.nextTick()等等
  2. 进入事件循环 利用一套面试题来分析一下执行时间吧:
async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
  }
async function async2(){
    console.log('async2')
}
console.log('script start')
setTimeout(function(){
    console.log('setTimeout0') 
},0)  
setTimeout(function(){
    console.log('setTimeout3') 
},3)  
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
async1();
new Promise(function(resolve){
    console.log('promise1')
    resolve();
    console.log('promise2')
}).then(function(){
    console.log('promise3')
})
console.log('script end')
//输出:script start、async1 start、async2、promise1、promise2、script end、nextTick、async1 end、promise3、setTimeout0、setImmediate、setTimeout3

分析:

  1. 首先执行脚步:输出script start、async1 start、async2、promise1、promise2、script end
  2. 脚本执行过后,
    • setTimeout队列中有:setTimeout0、setTimeout3
    • setImmediate队列中有:setImmediate
    • process.nextTick队列中有:nextTick
    • 微任务队列中有:async1 end、promise3
  3. 进入事件循环
    • 输出process.nextTick队列中的逻辑,也就是nextTick
    • promise:也就是async1 end、promise3
    • poll阶段
      • setTimeout0的时间到了,去执行setTimeout0的回调
      • setImmediate
      • setTimeout3

参考文章:
面试题:说说事件循环机制(满分答案来了)
微任务、宏任务、同步、异步、Promise、Async、await
极客时间 浏览器工作原理与实践
一道面试题引发的node事件循环深入思考
node官网指南