JS运行机制

JS运行机制

引题

JS单线程的原因

使用异步的原因

浏览器线程

GUI线程

JS引擎线程

定时器线程

事件触发线程

异步HTTP请求线程

总结

任务队列

同步任务和异步任务

事件和回调函数

微任务和宏任务

Event Loop

总体机制

异步执行机制

Event Loop核心执行机制

定时器

定时器执行机制

NodeJS的EventLoop

Poll阶段

setImmediate和setTimeout

async promise nextTick顺序

参考博客

 

引题

  1. JS是单线程语言
  2. Event Loop是JS的执行机制。深入了解JS的执行,就等于深入了解JS里的event loop(JS执行机制是一样的,但运行机制可能不一样 nodejs中)

JS单线程的原因

  1. JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
  2. 所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
  3. 为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。实则为“多 子线程”

使用异步的原因

  1. 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
  2. 如果JS中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。对于用户而言,阻塞就意味着"卡死",这样就导致了很差的用户体验

浏览器线程

JS的运行环境主要是浏览器,以大家都很熟悉的Chrome的内核为例,他不仅是多线程,而是多进程的:

一篇带你弄清楚JS运行机制--EventLoop

上图只是一个概括分类,意思是Chrome有这几类的进程和线程,并不是每种只有一个,比如渲染进程就有多个,每个选项卡都有自己的渲染进程。有时候我们使用Chrome会遇到某个选项卡崩溃或者没有响应的情况,这个选项卡对应的渲染进程可能就崩溃了,但是其他选项卡并没有用这个渲染进程,他们有自己的渲染进程,所以其他选项卡并不会受影响。这也是Chrome单个页面崩溃并不会导致浏览器崩溃的原因,而不是像老IE那样,一个页面卡了导致整个浏览器都卡。

对于前端工程师来说,主要关心的还是渲染进程,下面来分别看下里面每个线程是做什么的。

GUI线程

GUI线程就是渲染页面的,他解析HTML和CSS,然后将他们构建成DOM树和渲染树就是这个线程负责的。

JS引擎线程

这个线程就是负责执行JS的主线程,前面说的"JS是单线程的"就是指的这个线程。大名鼎鼎的Chrome V8引擎就是在这个线程运行的。需要注意的是,这个线程GUI线程是互斥的这也就解释了js执行时会阻塞页面的渲染互斥的原因是JS也可以操作DOM,如果JS线程和GUI线程同时操作DOM,结果就混乱了,不知道到底渲染哪个结果。这带来的后果就是如果JS长时间运行,GUI线程就不能执行,整个页面就感觉卡死了。

定时器线程

setTimeout和setInterval运行在这里,只是一个计时的作用,跟JS主线程根本不在同一个地方。并不会真正执行时间到了的回调,当时间到了定时器线程会将这个回调事件给到事件触发线程。

事件触发线程

事件触发线程将已经满足了触发条件的事件加到事件队列里面去。最终JS主线程从事件队列取出这个回调执行

异步HTTP请求线程

这个线程负责处理异步的ajax请求,当请求完成后,他也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行。

总结

JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程执行完主线任务后从任务队列取出任务事件继续执行

任务队列

同步任务和异步任务

  • 同步任务:在主线程排队支持的任务,前一个任务执行完毕后,执行后一个任务,形成一个执行栈,线程执行时在内存形成的空间为栈,进程形成堆结构,这是内存的结构。执行栈可以实现函数的层层调用。注意不要理解成同步代码进入栈中,按栈的出栈顺序来执行。
  • 异步任务:其回调函数被线程挂起,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

事件和回调函数

  • 事件:"任务队列"是一个事件的队列(也可以理解成消息的队列),异步任务条件被满足,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。

只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。主要事件:异步任务事件、定时器

  • "回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

微任务和宏任务

其实事件队列里面的事件还可以分两类:宏任务和微任务。微任务拥有更高的优先级,当事件循环遍历队列时,先检查微任务队列,如果里面有任务,就全部拿来执行,执行完之后再执行一个宏任务。执行每个宏任务之前都要检查下微任务队列是否有任务,如果有,优先执行微任务队列。

常见宏任务:

script (可以理解为外层同步代码)、setTimeout/setInterval、

setImmediate(Node.js)、I/O、UI事件、postMessage

常见微任务:

Promise、Async、process.nextTick(Node.js)、

Object.observe、MutaionObserver

一篇带你弄清楚JS运行机制--EventLoop

Event Loop

总体机制

一篇带你弄清楚JS运行机制--EventLoop

上图中,主线程运行的时候,产生堆(heap)和执行栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

异步执行机制

  1. 所有同步任务都在主线程上执行,形成一个执行栈。
  2. 主线程之外,还有一个“任务队列”,只要异步任务有了运行结果,就在“任务队列”之中放置一个事件。
  3. 一旦“执行栈”中的所有同步任务执行完毕了,系统就会读取“任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的三步。(事件循环)

一篇带你弄清楚JS运行机制--EventLoop

Event Loop核心执行机制

一篇带你弄清楚JS运行机制--EventLoop

  1. 整体的script(作为第一个宏任务)开始执行的时候,会把所有代码分为两部分:“同步任务”、“异步任务”;
  2. 同步任务会直接进入主线程依次执行;
  3. 异步任务会再分为宏任务和微任务;
  4. 宏任务进入到Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
  5. 微任务也会进入到另一个Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
  6. 当主线程内的任务执行完毕,主线程为空时,会先检查微任务的Event Queue,如果有任务,就全部执行。如果没有微任务才执行下一个宏任务;
  7. 上述过程会不断重复,这就是Event Loop事件循环;

注意:

  • 每一个EventLoop执行一次宏任务,微任务出现则当前EventLoop内执行完,出现宏任务则加入任务队列下一EventLoop内执行
  • 微任务队列全部执行完会重新渲染一次
  • 每个宏任务执行完都会重新渲染一次
  • requestAnimationFrame处于渲染阶段,不在微任务队列,也不在宏任务队列

定时器

定时器功能主要由setTimeout()和setInterval()这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行。以下主要讨论setTimeout()。

定时器执行机制

满足定时的时间----》任务队列注册事件----》

主线程执行栈清空+其任务队列之前的任务已完成----》执行回调

setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。

node.js里面setTimeout(fn, 0)会被强制改为setTimeout(fn, 1)

HTML 5里面setTimeout最小的时间限制是4ms

setInterval(fn,ms)来说,不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。

NodeJS的EventLoop

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

一篇带你弄清楚JS运行机制--EventLoop

  1. timers: 执行setTimeout和setInterval的回调
  2. pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调(异步任务事件)
  3. idle, prepare: 仅系统内部使用
  4. poll: 检索新的 I/O 事件(异步任务事件);执行与 I/O (异步任务事件)相关的回调。事实上除了其他几个阶段处理的事情,其他几乎所有的异步都在这个阶段处理。
  5. check: setImmediate在这里执行
  6. close callbacks: 一些关闭的回调函数,如:socket.on('close', ...)

注意每个阶段都有一个自己的先进先出的队列,只有当这个队列的事件执行完或者达到该阶段的上限时,才会进入下一个阶段。在每次事件循环之间,Node.js都会检查它是否在等待任何一个I/O(异步任务事件)或者定时器,如果没有的话,程序就关闭退出了。

Poll阶段

poll阶段的后面并不一定每次都是check阶段,poll队列执行完后,如果没有setImmediate但是有定时器到期,他会绕回去执行定时器阶段:

一篇带你弄清楚JS运行机制--EventLoop

setImmediate和setTimeout

异步流程中的setImmediate和setTimeout

console.log('outer');

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

结果:

一篇带你弄清楚JS运行机制--EventLoop

  1. 流程分析:
  2. 外层是一个setTimeout,所以执行他的回调的时候已经在timers阶段了
  3. 处理里面的setTimeout,因为本次循环的timers正在执行,所以他的回调其实加到了下个timers阶段
  4. 处理里面的setImmediate,将它的回调加入check阶段的队列
  5. 外层timers阶段执行完,进入pending callbacks,idle, prepare,poll,这几个队列都是空的,所以继续往下
  6. 到了check阶段,发现了setImmediate的回调,拿出来执行
  7. 然后是close callbacks,队列时空的,跳过
  8. 又是timers阶段,执行我们的console

同步任务中的setImmediate和setTimeout

console.log('outer');



setTimeout(() => {

  console.log('setTimeout');

}, 0);



setImmediate(() => {

  console.log('setImmediate');

});

结果:

一篇带你弄清楚JS运行机制--EventLoop

一篇带你弄清楚JS运行机制--EventLoop

  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。每次我们运行脚本时,机器状态可能不一样,导致运行时有1毫秒的差距,一会儿setTimeout先执行,一会儿setImmediate先执行。但是这种情况只会发生在还没进入timers阶段的时候。

其他例子

例一:在readFile的回调

var fs = require('fs')



fs.readFile(__filename, () => {

  setTimeout(() => {

    console.log('setTimeout');

  }, 0);

  setImmediate(() => {

    console.log('setImmediate');

  });

});

这里setTimeout和setImmediate在readFile的回调里面,由于readFile回调是I/O操作,他本身就在poll阶段,所以他里面的定时器只能进入下个timers阶段,但是setImmediate却可以在接下来的check阶段运行,所以setImmediate肯定先运行,他运行完后,去检查timers,才会运行setTimeout。

例二:在setImmediate的回调里面

console.log('outer');



setImmediate(() => {

  setTimeout(() => {

    console.log('setTimeout');

  }, 0);

  setImmediate(() => {

    console.log('setImmediate');

  });

});

原因跟写在最外层差不多,因为setImmediate已经在check阶段,里面的循环会从timers阶段开始,会先看setTimeout的回调,如果这时候已经过了1毫秒,就执行他,如果没过就执行setImmediate。

async promise nextTick顺序

promise和nextTick

同时有nextTick和Promise的时候,nextTick先执行

原因:nextTick的队列比Promise队列优先级更高

new Promise(function(resolve){
 
console.log("promise before");
  resolve();
}).
then(function(){
 
console.log("promise operating")
});

process.nextTick(()=>{
 
console.log('nextTick')
})

代码运行结果如下:

一篇带你弄清楚JS运行机制--EventLoop

promise和async

同时有promise和async的时候,promise先执行

原因:

async 函数中可能会有 await 表达式,这会使 async 函数暂停执行,等待(await)表达式中的 Promise 解析完成后继续执行 async 函数并返回解决结果。

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

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


new Promise(function(resolve){
 
console.log("promise before");
  resolve();
}).
then(function(){
 
console.log("promise operating")
});

代码运行结果如下:

一篇带你弄清楚JS运行机制--EventLoop

代码可以解释为:

在函数async1中,执行await(由于async2是async标记的函数,所以默认返回promise对象),发现resolve(),然后放入队列。

接着执行下方的new Promise中的resolve(),输出promise operating,再回来执行了输出async1 end。

async function async1(){
 
console.log('async1 start')
  async2(resolve => {
   
console.log('async2');
    resolve();
  }).
then( _ => {
     
console.log( 'async1 end ')
    })
  }

流程:

  1. 执行async1函数,输出async1 start
  2. 执行await async2函数,async2函数输出async2,返回promise对象。

promise对象放入到回调队列,await让出线程(此promise没收到resolve之前,async1无法向下继续执行)

  1. 执行new Promise,由于promise是立即执行函数输出promise before

其中的resolve()【promise resolve】被放在回调队列。

  1. 执行回调队列中,执行async2返回的promise对象,又产生resolve()。

resolve()【async2 resolve】被放入回调队列。这里不输出任何值。

  1. 执行回调队列中,resolve()【promise resolve】回调,输出promise operating
  2. 执行回调队列中,resolve()【async2 resolve】回调,执行
  3. async1继续执行,输出async1 end

(promise收到resolve()【async2 resolve】,async1执行,对应第二步)。

  1. finish

参考博客

setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop

这一次,彻底弄懂 JavaScript 执行机制

JS执行机制详解

浅谈js运行机制(线程)

JavaScript 运行机制详解:再谈Event Loop

详解promise、async和await的执行顺序

相关文章:

  • 2021-08-06
  • 2022-12-23
  • 2021-09-23
  • 2021-10-24
  • 2021-09-20
  • 2022-01-17
  • 2021-11-28
  • 2022-12-23
猜你喜欢
  • 2023-03-16
  • 2021-04-04
  • 2021-07-06
  • 2023-03-09
相关资源
相似解决方案