理解一下 js 的异步机制

509 阅读18分钟

前言

在 JavaScript 中,同步和异步是代码的执行方式,它们的区别在于代码的执行顺序和是否阻塞程序的执行。

  • 同步操作是指代码按顺序执行,会等待上一步操作完成后再执行。
  • 异步操作是指代码不会立即返回结果,而是在某个时间点触发回调函数执行。 常见的异步操作包括操作DOM元素,发送 AJAX 请求,读取文件等等。 由于这些操作需要等待一定的时间才能返回结果,因此需要采用异步编程模型来处理这些操作,以避免阻塞程序的执行。 常用的异步编程模型包括回调函数、promise、async/await等。

JS运行机制

JavaScript是一种单线程语言,这意味着它只能在一个主线程上运行。“JS的单线程”指的是JS 引擎线程。由于JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来更复杂的同步问题。 这个线程负责执行JavaScript代码,同时也负责处理浏览器中的用户事件、HTTP请求和其他任务。

JavaScript执行异步任务的机制可以分为宏任务和微任务:

宏任务:在主线程中执行的任务,例如异步代码、计时器回调函数和事件处理函数等。

微任务:优先级高于宏任务的任务,例如Promise、MutationObserver和HTML5的新API等。

渲染进程Renderer的主要线程

GUI渲染线程

  • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等

    • 解析html代码(HTML代码本质是字符串)转化为浏览器认识的节点,生成DOM树,也就是DOM Tree
    • 解析css,生成CSSOM(CSS规则树)
    • 把DOM Tree 和CSSOM结合,生成Rendering Tree(渲染树)
  • 当我们修改了一些元素的颜色或者背景色,页面就会重绘(Repaint)

  • 当我们修改元素的尺寸,页面就会回流(Reflow)

  • 当页面需要Repaing和Reflow时GUI线程执行,绘制页面

  • 回流(Reflow)比重绘(Repaint)的成本要高,我们要尽量避免Reflow和Repaint

  • GUI渲染线程与JS引擎线程是互斥的

    • 当JS引擎执行时GUI线程会被挂起(相当于被冻结了)
    • GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行

JS引擎线程

  • JS引擎线程就是JS内核,负责处理Javascript脚本程序(例如V8引擎)

  • JS引擎线程负责解析Javascript脚本,运行代码

  • JS引擎一直等待着任务队列中任务的到来,然后加以处理

    • 浏览器同时只能有一个JS引擎线程在运行JS程序,所以js是单线程运行的
    • 一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
  • GUI渲染线程与JS引擎线程是互斥的,js引擎线程会阻塞GUI渲染线程

    • 就是我们常遇到的JS执行时间过长,造成页面的渲染不连贯,导致页面渲染加载阻塞(就是加载慢)
    • 例如浏览器渲染的时候遇到<script>标签,就会停止GUI的渲染,然后js引擎线程开始工作,执行里面的js代码,等js执行完毕,js引擎线程停止工作,GUI继续渲染下面的内容。
    • 所以如果js执行时间太长就会造成页面卡顿的情况

事件触发线程

  • 属于浏览器而不是JS引擎,用来控制事件循环,并且管理着一个事件队列(task queue)
  • 当js执行碰到事件绑定和一些异步操作(如setTimeOut,也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会走事件触发线程将对应的事件添加到对应的线程中(比如定时器操作,便把定时器事件添加到定时器线程),等异步事件有了结果,便把他们的回调操作添加到事件队列,等待js引擎线程空闲时来处理。
  • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
  • 因为JS是单线程,所以这些待处理队列中的事件都得排队等待JS引擎处理

定时触发器线程

  • setInterval与setTimeout所在线程
  • 浏览器定时计数器并不是由JavaScript引擎计数的(因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确)
  • 通过单独线程来计时并触发定时(计时完毕后,添加到事件触发线程的事件队列中,等待JS引擎空闲后执行),这个线程就是定时触发器线程,也叫定时器线程
  • W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms

异步http请求线程

  • 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求

  • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由JavaScript引擎执行

  • 简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到事件队列,等待js引擎线程来执行

同步异步

js 的单线程分为: 同步任务、 异步任务

在 js 中存在 js 引擎执行的主线程,另一个是由事件触发管理的任务队列

  • 同步任务在主线程上排队执行,只有前一个任务执行完毕,才能顺序执行下一个任务。
  • 异步任务不进入主线程而是被js引擎加入到任务队列。一旦执行栈中的所有同步任务执行完毕主线程空闲时,系统就会读取任务队列,将可运行的异步任务添加到执行栈中。前一个任务是否执行完毕不影响下一个任务的执行。

微任务和宏任务都是异步任务,它们都属于一个队列

宿主环境

JavaScript是一种脚本语言,它需要在某个宿主环境中运行。宿主环境是指提供执行环境和相关API的系统或应用程序。不同的宿主环境提供了不同的JavaScript运行环境和API。 以下是一些常见的JavaScript宿主环境:

  1. Web浏览器 - 最常见的JavaScript宿主环境是Web浏览器,如Google Chrome,Mozilla Firefox,Safari和Microsoft Edge。Web浏览器提供了一个JavaScript环境,可以在网页中执行JavaScript脚本。此外,浏览器还提供了丰富的DOM API和Web API,用于操作HTML和CSS元素,与服务器通信以及访问本地存储。
  2. Node.js - Node.js是一个基于Chrome V8引擎的JavaScript运行环境,可以在服务器端运行JavaScript代码。它提供了许多内置的API和第三方库,以便在服务器端创建网络应用程序,处理文件和数据库等操作。
  3. Adobe Acrobat - Adobe Acrobat是一个PDF阅读器和编辑器应用程序,它提供了一个JavaScript环境,可以在PDF文件中执行JavaScript脚本。
  4. Adobe Photoshop - Adobe Photoshop是一款图像处理软件,它提供了一个JavaScript环境,可以使用JavaScript脚本扩展和自动化Photoshop的功能。

总之,JavaScript需要一个宿主环境才能运行。不同的宿主环境提供了不同的功能和API,可以满足各种需要。

在浏览器环境中,有JS 引擎线程和渲染线程,且两个线程互斥,而在Node环境中,只有JS 线程。

事件循环(Event Loop )

事件轮询

你的程序通常被打断成许多小的代码块,它们一个接一个地在事件轮询队列中执行。而且从技术上说,其他与你的程序没有直接关系的事件也可以穿插在队列中。

一个事件轮询将它的工作打碎成一系列任务并串行地执行它们,不允许并行访问和更改共享的内存。并行与“串行”可能以在不同线程上的事件轮询协作的形式共存。(通过分立线程中彼此合作的时间循环,并行和顺序执行可以共存)

JS到底是怎么运行的呢?

microtask 称为 jobs ===> 微任务, macrotask 称为 task ===>  宏任务

JS引擎常驻于内存中,等待宿主将JS代码或函数传递给它。
也就是等待宿主环境分配宏观任务,反复等待 - 执行即为事件循环。
Event Loop中,每一次循环称为tick,每一次tick的任务如下:

  • 执行栈选择最先进入队列的宏任务(一般都是script),执行其同步代码直至结束;

  • 检查是否存在微任务,有则会执行至微任务队列为空;

  • 如果宿主为浏览器,可能会渲染页面;

  • 开始下一轮tick,执行宏任务中的异步代码(setTimeout等回调)。

执行栈

执行栈,也叫调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文

当Javascript引擎开始执行你第一行脚本代码的时候,它就会创建一个全局执行上下文然后将它压到执行栈中

每当引擎碰到一个函数的时候,它就会创建一个函数执行上下文,然后将这个执行上下文压到执行栈中

引擎会执行位于执行栈栈顶的执行上下文(一般是函数执行上下文),当该函数执行结束后,对应的执行上下文就会被弹出,然后控制流程到达执行栈的下一个执行上下文

let a = 'Hello World!';
function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}
function second() {
  console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');

转化成图的形式

简单分析一下流程:

  • 创建全局上下文请压入执行栈
  • first函数被调用,创建函数执行上下文并压入栈
  • 执行first函数过程遇到second函数,再创建一个函数执行上下文并压入栈
  • second函数执行完毕,对应的函数执行上下文被推出执行栈,执行下一个执行上下文first函数
  • first函数执行完毕,对应的函数执行上下文也被推出栈中,然后执行全局上下文
  • 所有代码执行完毕,全局上下文也会被推出栈中,程序结束

宏任务 macro

宏任务是由宿主发起的,而微任务由JavaScript自身发起。

每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。

由于JS引擎线程和GUI渲染线程是互斥的 关系,浏览器为了能够使宏任务和DOM任务 能够有序的进行,会在一个宏任务执行结束后,在下一个宏任务执行开始前,GUI渲染线程开始工作,对页面进行重新渲染。

宏任务 -> GUI渲染 -> 宏任务 -> ...

宏任务:

  • setTimeout
  • setInterval
  • setImmediate()- node
  • requestAnimationFrame ()-浏览器

微任务 mic

可以理解是在当前 宏任务 执行结束后立即执行的任务。也就是说,在当前宏任务执行后,下一个宏任务之前。

所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。

宏任务 -> 微任务 -> GUI渲染 -> 宏任务 -> ...

JavaScript本身没有发起异步请求的能力,也就没有微任务的存在。

常见的微任务:

  • Promise.then()
  • await 行之后的内容
  • process.nextTick()
  • catch
  • finally
  • Object.observe
  • MutaionObserver

在ES5之后,JavaScript引入了Promise,这样,不需要浏览器,JavaScript引擎自身也能够发起异步任务了。

浏览器会先执行完一个宏任务,再执行当前执行栈的所有微任务,然后移交GUI渲染,同一个宏任务,全部执行完才会执行渲染,渲染时GUI线程会将所有UI改动优化合并。

图解宏任务、微任务:

所以,总结一下,两者区别为:

image.png

外层同步代码先执行,在执行过程中如果有微任务则执行微任务如 promise,微任务(promise)结束后继续执行同步代码,同步代码完成后返回执行promise.then的内容,最后执行宏任务中的异步代码(setTimeout等回调)

JS异步有一个机制,就是遇到宏任务,先执行宏任务,将宏任务放入事件队列,然后再执行微任务,将微任务放入事件队列,但是,这两个队列不是同一个。当你往外拿的时候先从微任务队列里拿这个回调函数,然后再从宏任务的队列中拿宏任务的回调函数,如下图:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染
  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

宏任务一般包括:整体代码script,setTimeout,setInterval。

微任务:Promise,process.nextTick

例题1:

console.log('start')     // 1  宏任务  外层同步代码

setTimeout(() => {            //宏任务 ==>  最后执行
  console.log('setTimeout')   //6
}, 0)

new Promise((resolve) => {    //微任务   先执行
  console.log('promise')      //2
  resolve()
})
  .then(() => {               //执行promise。then  回调
    console.log('then1')      //4
  })
  .then(() => {
    console.log('then2')      //5
  })
                          
console.log('end')             //3   微任务结束继续执行外层同步代码


//----------------------------结果------------------------------------
promise
end
then1
then2
setTimeout

async

async是通过Promise包装异步任务 ,async/await 是 Generator(生成器) 的语法糖。

  • async/await自带执行器,不需要手动调用next()就能自动执行下一步
  • async函数返回值是Promise对象,而Generator返回的是生成器对象
  • await 后面是promise对象,能够返回Promise的resolve/reject的值

async函数内部的异常可以通过 .catch()或者 try/catch来捕获,区别是:

  • try/catch 能捕获所有异常,try语句抛出错误后会执行catch语句,try语句内后面的内容不会执行
  • catch() 只能捕获异步方法中reject错误,并且catch语句之后的语句会继续执行

setTimeout,setImmediate

setTimeout、setInterval、setImmediate都是JavaScript中常用的定时器方法,但它们有着不同的用途和实现方式:

  • setTimeout()方法是在指定的时间后调用一次函数。

  • setInterval()方法用于按照指定的时间间隔重复执行一个函数,直到被清除为止。

  • setImmediate()则在事件循环中使用了一个特殊的检测点,setImmediate将始终在setTimeout前执行

在Node.js中,setTimeout()和setImmediate()是两种不同的实现方式,其中setTimeout()利用了计时器,而setImmediate()则在事件循环中使用了一个特殊的检测点。

setImmediateprocess.nextTick为Node环境下常用的方法(IE11支持setImmediate),所以,后续的分析都基于Node宿主。

Node.js是运行在服务端的js,虽然用到也是V8引擎,但由于服务目的和环境不同,导致了它的API与原生JS有些区别,其Event Loop还要处理一些I/O,比如新的网络连接等,所以与浏览器Event Loop不太一样。

Nodejs 中事件循环的执行顺序如下:

  1. timer 阶段: 检查计时器队列,执行setTimeout和setInterval的回调

  2. pending callbacks 阶段: 执行延迟到下一个循环迭代的 I/O 回调

  3. idle, prepare 阶段: 仅系统内部使用,无需干预直接跳过即可

  4. poll 阶段: 检索新的 I/O 事件;执行与 I/O 相关的回调,比如读取文件、接收网络请求等。事实上除了其他几个阶段处理的事情,其他几乎所有的异步都在这个阶段处理。

  5. check检测队列阶段: 执行回调函数 setImmediate的回调在这里执行

  6. close callbacks 阶段: 执行所有的close事件,比如关闭文件和 socket 连接等。

Node.js事件循环的执行顺序是固定的,且每个阶段都有其特定的用途和执行顺序。熟悉和理解Node.js事件循环的执行顺序对于理解异步编程和防止程序阻塞非常重要。
一般来说,setImmediate会在setTimeout之前执行,

这是因为setTimeout()会将回调函数放入计时器队列,该计时器将在下一轮事件循环迭代的 Timer 阶段执行,而不是立即执行。

而 setImmediate() 会将回调函数放入检测队列,并在当前阶段的 I/O 事件的回调函数之后执行。

console.log('outer');
setTimeout(() => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
}, 0);

其执行顺序为:

  1. 外层是一个setTimeout,所以执行它的回调的时候已经在timers阶段了
  1. 处理里面的setTimeout,因为本次循环的timers正在执行,所以其回调其实加到了下个timers阶段
  1. 处理里面的setImmediate,将它的回调加入check阶段的队列
  1. 外层timers阶段执行完,进入pending callbacks,idle, prepare,poll,这几个队列都是空的,所以继续往下
  1. 到了check阶段,发现了setImmediate的回调,拿出来执行
  1. 然后是close callbacks,队列是空的,跳过
  1. 又是timers阶段,执行console.log('setTimeout')
    但是,如果当前执行环境不是timers阶段,就不一定了。。。。

注意:在nodejs中,如果setTimeout()的延迟时间设置为0,它实际上仍需要一定的时间来执行 setTimeout(fn, 0)会被强制为setTimeout(fn, 1), 将在 1毫秒后执行

看看下面的例子:

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

setImmediate(() => {
  console.log('setImmediate');
});

其执行顺序为:

  1. 外层同步代码一次性全部执行完,遇到异步API就塞到对应的阶段
  2. 遇到setTimeout,虽然设置的是0毫秒触发,但是被node.js强制改为1毫秒,塞入times阶段
  3. 遇到setImmediate塞入check阶段
  4. 同步代码执行完毕,进入Event Loop
  5. 先进入times阶段,检查当前时间过去了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
  6. 跳过空的阶段,进入check阶段,执行setImmediate回调

可见,1毫秒是个关键点,如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout回调会被执行,如果1毫秒还没到,就先执行了setImmediate。所以在上面的例子中 setTimeout 会在setImmediate 之后执行。

如果将setTimeout()和setImmediate()都放在同一I/O循环中,setImmediate()将始终比setTimeout()先执行。

Promise,process.nextTick

Promise: 在 new Promise 时传入的回调函数为同步代码 ,会立即执行,而.then() .catch()里的为 异步微任务。 在 promise 的 状态发生变更后 将不会再被改变。

process.nextTick() 为Node环境下的方法,是一个特殊的异步API,其实并不是事件循环的一部分。事实上Node在遇到这个API时,process.nextTick() 函数的回调函数会在当前事件循环迭代结束之前执行,等待回调执行完后才会继续下一个 Event Loop。

  • process.nextTick 中的回调是在当前 tick 执行完之后,下一个宏任务执行之前调用。所以process.nextTick() 会在当前 tick 的同一个阶段立即执行。
  • process.nextTick() 的优先级非常高,在 Node.js 中,process.nextTick() 会先于 Promise、setTimeout、setImmediate() 之前执行。

所以,process.nextTickPromise同时出现时,肯定是process.nextTick先执行,原因是process.nextTick的队列比Promise队列优先级更高。

Vue中的  vm.$nextTick

vm.$nextTick 接受一个回调函数作为参数,用于将回调延迟到下次DOM更新周期之后执行。

这个API就是基于事件循环实现的。

“下次DOM更新周期”的意思就是下次微任务执行时更新DOM,而vm.$nextTick就是将回调函数添加到微任务中(在特殊情况下会降级为宏任务)。

因为微任务优先级太高,Vue 2.4版本之后,提供了强制使用宏任务的方法。

  • vm.$nextTick优先使用Promise,创建微任务。
  • 如果不支持Promise或者强制开启宏任务,那么,会按照如下顺序发起宏任务:
  • 优先检测是否支持原生 setImmediate(这是一个高版本 IE 和 Edge 才支持的特性)
  • 如果不支持,再去检测是否支持原生的MessageChannel
  • 如果也不支持的话就会降级为 setTimeout。

总结

总的来说,JavaScript 异步操作的处理机制,通过将异步操作封装成宏任务或微任务,把不同任务放到对应的异步队列中,并通过事件循环来协调异步操作的执行顺序和时间。

希望各位看到这里的朋友们能有所收获,如果有疑问或是文章有误欢迎大家留言啊 !!!

参考:

  1. 【Promise面试题】:juejin.cn/post/684490…
  2. 【js运行机制】:juejin.cn/post/684490…
  3. 【理解 process.nextTick()】: www.jianshu.com/p/deb8bc589…