JavaScript运行机制探索:事件环解析(event loop)

Event Loop 是指的是计算机系统的一种运行机制,JavaScript语言就采用这种机制,来解决单线程运行带来的一些问题。想要理解Event Loop,就要从程序的运行模式讲起。

目录摘要:

  1. 为什么JavaScript是单线程?
  2. 任务队列
    • Event Loop
    • Node.js的Event Loop

文章内容参考其他文献后自己理解,可能有错误理解的地方,希望能和大家探讨一下,欢迎批评指正!

我们来看一个网上的面试题:

 console.log('main1');
process.nextTick(function() {
    console.log('process.nextTick1');
});
setTimeout(function() {
    console.log('setTimeout');
    process.nextTick(function() {
        console.log('process.nextTick2');
    });
}, 0);
new Promise(function(resolve, reject) {
    console.log('promise');
    resolve();
}).then(function() {
    console.log('promise then');
});

console.log('main2');

复制代码

分析: JS代码开始从上自下单线程执行:

*  1.console.log('main1');进入执行栈执行
*  2.遇到process.nextTick将它的回调函数先放入MicroTask(微任务)
* 3.遇到setTimeout将它的回调函数放入MacroTask(宏任务队列)
*  4.在执行栈中new Promise 并将.then中注册的回调放入MicroTask
*  5.最后一行代码console.log('main2');会放入主执行栈执行


综上所述,这段代码的结果就是先输出main1,
然后第二步第三步我们不用管它,它不是在主执行栈中,所以直接到第四步输出promise,
然后主执行栈继续执行第五步输出main2。此时主执行栈执行完毕,开始事件循环,
发现在MicroTask中还有任务,开始清空微任务,
第二步中我们在微任务中放入了process.nextTick所以输出process.nextTick1,
在第四步中将.then的回调放入了微任务,那么微任务队列继续执行输出
promise then,此时微任务队列已经清空开始事件循环宏任务队列,也就是输出第三步中的setTimeout
,在输出之后发现setTimeout这个回调中还有一个process.nextTick,
那么这个回调继续放入微任务队列,此时事件循环发现主执行栈中已经没有任务,
那么开始执行MicroTask输出:process.nextTick2
复制代码

了解了JavaScript的运行机制,就能更好的了解JavaScript。而

一、为什么JavaScript是单线程?


想要理解Event Loop,就要从程序的运行模式讲起。运行以后的程序叫做"进程"(process)。学习JavaScript时,我们就知道JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。

名词解释:

1. 线程和进程
  • 线程,有时被称为轻量级进程(Lightweight Process,LWP)。

    线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个 进程的其它线程共享进程所拥有的全部资源。

    一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。

    由于线程之间的相互制约,致使线程 在运行中呈现出间断性。

    线程也有就绪、阻塞和运行三种基本状态。就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。

    多线程靠的是切换时间片,这个过程中可能会浪费资源;计算在分配资源时,按照进程为单位。而node 中是单线程(主线程),并且是异步非阻塞的。

  • 进程是操作系统分配资源和调度任务的基本单位,线程是建立在进程上的一次程序运行单位,一个进程上可以有多个线程。

1.1 浏览器渲染引擎

  • 渲染引擎内部是多线程的,内部包含两个最为重要的线程ui线程和js线程。这里要特别注意ui线程和js线程是互斥的,因为JS运行结果会影响到ui线程的结果。ui更新会被保存在队列中等到js线程空闲时立即被执行。

1.2 js单线程(为什么JavaScript是单线程)

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

这里所谓的JavaScript是单线程指的是主线程是单线程的,所以在Node.js中主线程也是单线程的。

1.3 其他线程

  • 浏览器事件触发线程(用来控制事件循环,存放setTimeout、浏览器事件、ajax的回调函数)
  • 定时触发器线程(setTimeout定时器所在线程)
  • 异步HTTP请求线程(ajax请求线程)

二、任务队列


单线程特点是节约了内存,并且不需要在切换执行上下文。但是也意味着所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。那比如有很多的如Ajax的这种弄从网络读取数据的,很慢的这种,这时就不得不等着结果出来,再往下执行。那这种在JavaScript中怎么运行机制解决的呢?

写过如ajax调取后台接口,我们知道这是可以异步方法的运行。即这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。在具体看下面讲解:

1. JavaScript 中的 event loop

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。

首先来理解两概念:

  • 同步任务和异步任务

    所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

  1. 所有同步任务都在主线程上执行,形成一个执行栈
  2. 主线程之外,还存在一个任务队列。只要异步任务有了运行结果,就在任务队列之中放置一个事件。
  3. 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,将队列中的事件放到执行栈中依次执行
  4. 主线程从任务队列中读取事件,这个过程是循环不断的
  • 任务队列中的:事件、回调函数和定时器

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

    • "任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。

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

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

      setTimeout()接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。

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

ps:
  • HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。这时使用requestAnimationFrame()的效果要好于setTimeout()。

  • 需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。

//栈中执行的代码
function step1() {
   console.log(1);
}

function step2() {
   console.log(2);
   step1();
}

setTimeout(() => {
   console.log(3)
});

step2();

// 2
// 1
// 3
复制代码

执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。

//
setTimeout(() => {
    console.log(3)
});
console.log(1);

// 1
// 3
复制代码
//
console.log(1);
setTimeout(() => {
    console.log(3)
});


// 1
// 3
复制代码

图中的微任务和宏任务又是什么呢?

Js 中,有两类任务队列:宏任务队列(macro tasks)和微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。那么什么任务,会分到哪个队列呢?

  • 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering.

  • 微任务:process.nextTick, Promise, Object.observer, MutationObserver.

  • 任务队列中,取任务来执行。共分3步:

  1. 取一个宏任务来执行。执行完毕后,下一步。
  2. 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
  3. 更新UI渲染。

Event Loop 会无限循环执行上面3步,这就是Event Loop的主要控制逻辑。其中,第3步(更新UI渲染)会根据浏览器的逻辑,决定要不要马上执行更新。毕竟更新UI成本大,所以,一般都会比较长的时间间隔,执行一次更新。

浏览器的事件环是先执行微任务再执行宏任务,但是这里面有个代码编译的时间线,不会把队列里的都执行完。(跟node不同)

2. node.js 中的 event loop

Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器环境。

请看下面的示意图(作者@BusyRich)。

  1. 我们写的js代码会交给v8引擎进行处理
  2. 代码中可能会调用nodeApi,node会交给libuv库处理
  3. libuv通过阻塞i/o和多线程实现了异步io
  4. 通过事件驱动的方式,将结果放到事件队列中,最终交给我们的应用。

ps:注意上面六个阶段都不包括 process.nextTick()

这里每一个阶段都对应一个事件队列,当event loop执行到某个阶段时会将当前阶段对应的队列依次执行。当队列执行完毕或者执行的数量超过上线时,会转入下一个阶段。这里我们重点关注poll阶段

2.1 poll阶段

在node.js里,任何异步方法(除timer,close,setImmediate之外)完成时,都会将其callback加到poll queue里,并立即执行。

poll 阶段有两个主要的功能:

  1. 处理poll队列(poll quenue)的事件(callback);
  2. 执行timers的callback,当到达timers指定的时间时;

如果event loop进入了 poll阶段,且代码未设定timer,将会发生下面情况:

  • 如果poll queue不为空,event loop将同步的执行queue里的callback,直至queue为空,或执行的callback到达系统上限;

  • 如果poll queue为空,将会发生下面情况:

    • 如果代码已经被setImmediate()设定了callback, event loop将结束poll阶段进入check阶段,并执行check阶段的queue (check阶段的queue是 setImmediate设定的)
    • 如果代码没有设定setImmediate(callback),event loop将阻塞在该阶段等待callbacks加入poll queue; 如果event loop进入了 poll阶段,且代码设定了timer:
  • 如果poll queue进入空状态时(即poll 阶段为空闲状态),event loop将检查timers,

  • 如果有1个或多个timers时间时间已经到达,event loop将按循环顺序进入 timers 阶段,并执行timer queue.

2.2 setImmediate与setTimeout
  • setImmediate 设计在poll阶段完成时执行,即check阶段;
  • setTimeout 设计在poll阶段为空闲时,且设定时间到达后执行;但其在timer阶段执行

其二者的调用顺序取决于当前event loop的上下文,如果他们在异步i/o callback之外调用,其执行先后顺序是不确定的

setImmediate(function A() {
  console.log(1);
  setImmediate(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('输出setTimeout');
}, 0);
复制代码

上面代码中,setImmediate与setTimeout(fn,0)各自添加了一个回调函数A和timeout,都是在下一次Event Loop触发。那么,哪个回调函数先执行呢?答案是不确定。运行结果可能是:1--输出setTimeout--2,也可能是:输出setTimeout--1--2。

setImmediate(function (){
  setImmediate(function A() {
    console.log(1);
    setImmediate(function B(){console.log(2);});
  });

  setTimeout(function timeout() {
    console.log('输出setTimeout');
  }, 0);
});
// 1
// 输出setTimeout
// 2
复制代码

上面代码中,setImmediate和setTimeout被封装在一个setImmediate里面,它的运行结果总是1--输出setTimeout--2,这时函数A一定在timeout前面触发。至于2排在TIMEOUT FIRED的后面(即函数B在timeout后面触发),是因为setImmediate总是将事件注册到下一轮Event Loop,所以函数A和timeout是在同一轮Loop执行,而函数B在下一轮Loop执行

2.3 process.nextTick

process.nextTick()不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('输出setTimeout');
}, 0)
// 1
// 2
// 输出setTimeout
复制代码

process.nextTick方法可以在当前"执行栈"的尾部----下一次Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次Event Loop时执行

process.nextTick(function foo() {
  process.nextTick(foo);
});
复制代码

ps:事实上,现在要是你写出递归的process.nextTick,Node.js会抛出一个警告,要求你改成setImmediate。

process.nextTick与setImmediate区别

  • process.nextTick() 函数是在任何阶段执行结束的时刻
  • setImmediate() 函数是在poll阶段后进去check阶段事执行
  • 由于process.nextTick指定的回调函数是在本次"事件循环"触发,而setImmediate指定的是在下次"事件循环"触发,所以很显然,前者总是比后者发生得早
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值