js Event Loop 运行机制

4,122 阅读11分钟

Event Loop,事件环,线程进程。这些概念对初识前端的同学来说可能会一头雾水。而且运行js代码的运行环境除了浏览器还有node。因此不同环境处理Event Loop又变得不同,十分容易混淆。如果你有这样的疑问。下文将给你一个清晰的解释。

概念梳理

首先我们简化一下概念,把进程,线程,事件环,这些概念梳理一下。清晰了概念后面用到的时候就会有共鸣。

进程和线程基本概念

拿出在教科书里的概念:

1、调度:线程作为调度和分配的基本单位,进程作为拥有资源的基本单位;

2、并发性:不仅进程之间可以并发执行,同一个进程的多个线程之间也可并发执行;

3、拥有资源:进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源;

4、系统开销:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销。

进程和线程的关系:

  1. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程;
  2. 资源分配给进程,同一进程的所有线程共享该进程的所有资源;
  3. 处理机分给线程,即真正在处理机上运行的是线程;
  4. 线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的办法实现同步。线程是指进程内的一个执行单元,也是进程内的可调度实体。
    第一次看可能并没什么共鸣。但是带着最基本的想法,一个进程可以有多个线程,线程之间可以相互通信。这两点,就足够你理解后续事件环的知识。

浏览器中的进程和线程和Event Loop

浏览器的进程

  1. 从打开浏览器开始,打开浏览器,我们首先看到的是,用户界面,这里有搜索框,显示区,还有收藏夹等等。这些会分配一个进程。
  2. 我们看到浏览器自己会实现一些本地存储,cookie等,这些操作也需要分配一个进程。

3. 打开一个浏览器的tab页,他如果想要运行就需要系统分配给他cpu和内存资源,因此他需要分配一个进程。对应上面的概念“进程是拥有资源的基本单位”。因此每打开一个tab就对应一个新的进程。从资源管理器中进程可以看到,chrome占用多个进程。(有些系统会对进程进行整合,win10下可能看到的效果不同)

眼见为实,我们才能说浏览器是多线程的。那我们用可视化的角度看一下浏览器的这个进程和线程结构

从图中看黄色的圆角框里包裹的都是进程。蓝色的直角框里包裹的都是浏览器渲染引擎(浏览器内核)所包含的线程。对应上面的概念“一个进程可以有多个线程,但至少有一个线程”。前三个进程刚刚在1-3里都说过了。 介绍了刚刚那么多我们前端的操作其实都是在3中浏览器渲染引擎中处理。真正干活的就是线程。对应上面的概念“处理机分给线程,即真正在处理机上运行的是线程”。

浏览器内核的线程

接下来看一下浏览器引擎(进程)中包含哪些线程

  • UI渲染线程 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行

注意:UI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),UI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

  • js引擎线程(JS解析线程) 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎) JS引擎线程负责解析Javascript脚本,运行代码。 JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都__只有一个JS线程在运行JS程序__

同样注意:UI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

  • 事件触发线程 __归属于浏览器__而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助) 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理

注意:由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)

  • 定时触发器线程 传说中的setInterval与setTimeout所在线程 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确) 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)

注意:W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

  • 异步http请求线程 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

js渲染引擎的Event Loop

以上线程,每个拿出来都可以详细的说上一篇。Event Loop涉及到的JS引擎的一些运行机制的分析。我们可以将这些线程理解为,

  • 一个主进程就是js引擎,其他均为辅助的线程。
  • 主进程存在一个执行栈,事件触发线程维护一个消息队列
  • 同步任务在执行栈中执行,异步任务在满足条件后加入到消息队列中,等待执行。
  • 先执行栈中的任务,执行完毕后,检查队列是否为空,不为空,将队列中的任务压入执行栈中执行。直到栈和队列均为空。 js渲染引擎的Event Loop如下图

这时候拿出几道题看一下会更清晰 题目1:

setTimeout(function(){
    console.log(0)
},500)
setTimeout(function(){
    console.log(1)
},1000)
setTimeout(function(){
    console.log(2)
},2000)

for(;;){

}

上面这段代码用于不会有输出,同步代码死循环阻塞了执行栈。虽然定时后回调加入执行队列,但是异永远不会执行。
题目二:

setTimeout(function(){
    console.log('setTimeout1');
    Promise.resolve().then(()=>{
        console.log('then1');
    });
},0)
Promise.resolve().then(()=>{
    console.log('then2');
    Promise.resolve().then(()=>{
        console.log('then3');
    })
    setTimeout(function(){
        console.log('setTimeout2');
    },0)
})

答案:then2 then3 setTimeout1 then1 setTimeout2
首先在题目中出现了es6的promise,他的出现让原来我们理解的__事件环产生了一些不同__。 为什么呢?因为Promise里有了一个一个新的概念:microtask 此时JS中分为__两种任务类型__:macrotask和microtask,在ECMAScript中,microtask称为jobs,macrotask可称为task

微任务和宏任务

首先说明,是以__浏览器为处理环境__下的执行逻辑 浏览器环境下的微任务和宏任务有哪些 宏任务:setTimeout setImmediate MessageChannel 微任务:Promise.then MutationObserver
记住两点:

  • 微任务在宏任务之前的执行,先执行 执行栈中的内容 执行后 清空微任务
  • 每次取一个宏任务 就去清空微任务,之后再去取宏任务

然后题目入手分析宏任务和微任务的执行

  • setTimeout1放入宏任务执行队列中,微任务then2放入微任务队列中,栈为空,优先执行微任务,则先执行then2。
  • then2之后执行后,接下来存在微任务then3。将then3放入微任务队列中。
  • 接下来setTimeout2加入到宏任务队列中。
  • 此时执行栈为空,执行then3。
  • 微任务全部执行完毕后,执行宏任务setTimeout1,执行发现微任务then1,放置到微任务队列中。
  • setTimeout1宏任务执行完,再次清空微任务队列,执行then1
  • 微任务全部执行完毕后,执行宏任务setTimeout2。程序结束。

node运行环境中的进程和线程

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。他的目标就是解析js代码,让他能运行起来。

node js 是单线程的

和浏览器环境下类似,他有一个解析js的主线程,其他线程作为辅助,但是因为不涉及操作dom,ui线程就不存在了。(各个线程的概念参考浏览器环境下的线程)
单线程在浏览器运行环境中的弊端体现在阻塞页面执行。
那么node作为后端服务,单线程有什么利弊?
优点:

  1. 避免频繁创建、切换进程的开销,使执行速度更加迅速。
  2. 资源占用小
  3. 线程安全,不用担心同一变量同时被多个线程进行读写而造成的程序崩溃。 缺点:
  4. 不适合大量的计算和压缩等cpu密集型的操作,会造成阻塞。

node下Event Loop

事件环的整体还是不变的,执行栈,消息队列,api。不同的是,node下的消息队列有所不同

分析一下node下的消息队列

  • 为微任务,定时器,io,setImmidiate分别分配消息队列
  • 先检查定时器队列,如果有内容,则全部清空
  • 从时间队列切换到io队列的过程中,检查微任务,如果有则情况微任务。
  • io队列执行完成,如果有check队列的内容,则执行。否则继续检查定时器队列。
  • 完成闭环

从一个题目入手感受一下node环境和浏览器环境下的不同

setTimeout(() => {
    console.log('timeout1');
    Promise.resolve().then(() => {
        console.log('promise');
    });

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

浏览器下的结果:timeout1 promise timeout2
node下的结果:timout1 timeout2 promise

微任务和宏任务

node环境下的微任务和宏任务有哪些 宏任务:setTimeout setImmediate 微任务:Promise.then process.nextTick
题目三可以很好的分析node环境下的任务执行 node环境下运行流程

  • 首先遇到两个宏任务,均放入到时间队列里。
  • 执行时间队列里第一个宏任务时timeout1,遇到微任务promise,放到微任务队列中
  • 此时时间队列还未清空,继续执行完成所有时间队列里的任务。执行timout2
  • 在切换io队列时检查微任务,有则执行清空微任务。执行promise。
    浏览器环境下运行流程
  • 首先遇到两个宏任务,均放入到宏任务队列里。
  • 执行时间队列里第一个宏任务时timeout1,遇到微任务promise,放到微任务队列中
  • timout1执行完成检查微任务,有内容则执行清空,执行promise。
  • 清空微任务后再执行宏任务。执行timeout2

注意:同样是微任务,process.nextTick,优于promise.then先执行

Promise.resolve().then(() => {
    console.log('then')
})
process.nextTick(() => {
    console.log('nextTick')
});
//nextTick then

注意:同样是宏任务,setTimeout和setImediate执行的先后顺序是不确定的,依赖于执行栈执行的速度。

setImmediate(function () {
    console.log('setImmediate')
});
setTimeout(function () {
    console.log('setTimeout')
}, 0); // ->4

但是在如下场景下是有固定输出的

let fs = require('fs');
fs.readFile('./gitignore', function () { // io的下一个事件队列是check阶段
    setImmediate(function () {
        console.log('setImmediate')
    });
    setTimeout(function () {
        console.log('setTimeout')
    }, 0); // ->4
})

给个提示,读文件是io操作,io执行之后首先要check,check之后或没有check内容再去检查定时队列。 那么结果就留给大家自行分析了。

总结

希望这篇文章能给初识js的你一个清晰的大框,也是梳理我自己的知识。可能我理解的也很粗浅,有错误的地方,希望大家帮忙指正。

参考文献

  1. 从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
  2. Node.js的线程和进程详解