详解JavaScript中的Event Loop(事件循环)机制

前言

咱们都知道,javascript从诞生之日起就是一门单线程的非阻塞的脚本语言。这是由其最初的用途来决定的:与浏览器交互。javascript

单线程意味着,javascript代码在执行的任什么时候候,都只有一个主线程来处理全部的任务。java

而非阻塞则是当代码须要进行一项异步任务(没法马上返回结果,须要花必定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,而后在异步任务返回结果的时候再根据必定规则去执行相应的回调。node

单线程是必要的,也是javascript这门语言的基石,缘由之一在其最初也是最主要的执行环境——浏览器中,咱们须要进行各类各样的dom操做。试想一下 若是javascript是多线程的,那么当两个线程同时对dom进行一项操做,例如一个向其添加事件,而另外一个删除了这个dom,此时该如何处理呢?所以,为了保证不会 发生相似于这个例子中的情景,javascript选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。web

固然,现现在人们也意识到,单线程在保证了执行顺序的同时也限制了javascript的效率,所以开发出了web worker技术。这项技术号称让javascript成为一门多线程语言。ajax

然而,使用web worker技术开的多线程有着诸多限制,例如:全部新线程都受主线程的彻底控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并无执行I/O操做的权限,只能为主线程分担一些诸如计算等任务。因此严格来说这些线程并无完整的功能,也所以这项技术并不是改变了javascript语言的单线程本质。chrome

能够预见,将来的javascript也会一直是一门单线程的语言。api

话说回来,前面提到javascript的另外一个特色是“非阻塞”,那么javascript引擎究竟是如何实现的这一点呢?答案就是今天这篇文章的主角——event loop(事件循环)。浏览器

注:虽然nodejs中的也存在与传统浏览器环境下的类似的事件循环。然而二者间却有着诸多不一样,故把二者分开,单独解释。多线程

正文

浏览器环境下js引擎的事件循环机制

1.执行栈与事件队列

当javascript代码执行的时候会将不一样的变量存于内存中的不一样位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。 可是咱们这里说的执行栈和上面这个栈的意义却有些不一样。dom

咱们知道,当咱们调用一个方法的时候,js会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中存在着这个方法的私有做用域,上层做用域的指向,方法的参数,这个做用域中定义的变量以及这个做用域的this对象。 而当一系列方法被依次调用的时候,由于js是单线程的,同一时间只能执行一个方法,因而这些方法被排队在一个单独的地方。这个地方被称为执行栈。

当一个脚本第一次执行的时候,js引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,而后从头开始执行。若是当前执行的是一个方法,那么js会向执行栈中添加这个方法的执行环境,而后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,js会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。。这个过程反复进行,直到执行栈中的代码所有执行完毕。

下面这个图片很是直观的展现了这个过程,其中的global就是初次运行脚本时向执行栈中加入的代码:

 

 

 

 

从图片可知,一个方法执行会向执行栈中加入这个方法的执行环境,在这个执行环境中还能够调用其余方法,甚至是本身,其结果不过是在执行栈中再添加一个执行环境。这个过程能够是无限进行下去的,除非发生了栈溢出,即超过了所能使用内存的最大值。

以上的过程说的都是同步代码的执行。那么当一个异步代码(如发送ajax请求数据)执行后会如何呢?前文提过,js的另外一大特色是非阻塞,实现这一点的关键在于下面要说的这项机制——事件队列(Task Queue)。

js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其余任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不一样的另外一个队列,咱们称之为事件队列。被放入事件队列不会马上执行其回调,而是等待当前执行栈中的全部任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。若是有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,而后执行其中的同步代码...,如此反复,这样就造成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的缘由。

这里还有一张图来展现这个过程:

 

 

图中的stack表示咱们所说的执行栈,web apis则是表明一些异步事件,而callback queue即事件队列。

2.macro task与micro task

以上的事件循环过程是一个宏观的表述,实际上由于异步任务之间并不相同,所以他们的执行优先级也有区别。不一样的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

如下事件属于宏任务:

  • setInterval()
  • setTimeout()

如下事件属于微任务

  • new Promise()
  • new MutaionObserver()

前面咱们介绍过,在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。而且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。若是不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;若是存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,而后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈...如此反复,进入循环。

咱们只需记住当当前执行栈执行完毕时会马上先处理全部微任务队列中的事件,而后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务以前执行。

这样就能解释下面这段代码的结果:

setTimeout(function () {
    console.log(1);
});

new Promise(function(resolve,reject){
    console.log(2)
    resolve(3)
}).then(function(val){
    console.log(val);
})

结果为:

2
3
1

node环境下的事件循环机制

1.与浏览器环境有何不一样?

在node中,事件循环表现出的状态与浏览器中大体相同。不一样的是node中有一套本身的模型。node中事件循环的实现是依靠的libuv引擎。咱们知道node选择chrome v8引擎做为js解释器,v8引擎将js代码分析后去调用对应的node api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不一样的事件放在不一样的队列中等待主线程执行。 所以实际上node中的事件循环存在于libuv引擎中。

2.事件循环模型

下面是一个libuv引擎中的事件循环的模型:

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

注:模型中的每个方块表明事件循环的一个阶段

这个模型是node官网上的一篇文章中给出的,我下面的解释也都来源于这篇文章。我会在文末把文章地址贴出来,有兴趣的朋友能够亲自与看看原文。

3.事件循环各阶段详解

从上面这个模型中,咱们能够大体分析出node中的事件循环的顺序:

外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段...

以上各阶段的名称是根据我我的理解的翻译,为了不错误和歧义,下面解释的时候会用英文来表示这些阶段。

这些阶段大体的功能以下:

  • timers: 这个阶段执行定时器队列中的回调如 setTimeout()setInterval()
  • I/O callbacks: 这个阶段执行几乎全部的回调。可是不包括close事件,定时器和setImmediate()的回调。
  • idle, prepare: 这个阶段仅在内部使用,能够没必要理会。
  • poll: 等待新的I/O事件,node在一些特殊状况下会阻塞在这里。
  • check: setImmediate()的回调会在这个阶段执行。
  • close callbacks: 例如socket.on('close', ...)这种close事件的回调。

下面咱们来按照代码第一次进入libuv引擎后的顺序来详细解说这些阶段:

poll阶段

当个v8引擎将js代码解析后传入libuv引擎后,循环首先进入poll阶段。poll阶段的执行逻辑以下: 先查看poll queue中是否有事件,有任务就按先进先出的顺序依次执行回调。 当queue为空时,会检查是否有setImmediate()的callback,若是有就进入check阶段执行这些callback。但同时也会检查是否有到期的timer,若是有,就把这些到期的timer的callback按照调用顺序放到timer queue中,以后循环会进入timer阶段执行queue中的 callback。 这二者的顺序是不固定的,收到代码运行的环境的影响。若是二者的queue都是空的,那么loop会在poll阶段停留,直到有一个i/o事件返回,循环会进入i/o callback阶段并当即执行这个事件的callback。

值得注意的是,poll阶段在执行poll queue中的回调时实际上不会无限的执行下去。有两种状况poll阶段会终止执行poll queue中的下一个回调:1.全部回调执行完毕。2.执行数超过了node的限制。

check阶段

check阶段专门用来执行setImmediate()方法的回调,当poll阶段进入空闲状态,而且setImmediate queue中有callback时,事件循环进入这个阶段。

close阶段

当一个socket链接或者一个handle被忽然关闭时(例如调用了socket.destroy()方法),close事件会被发送到这个阶段执行回调。不然事件会用process.nextTick()方法发送出去。

timer阶段

这个阶段以先进先出的方式执行全部到期的timer加入timer队列里的callback,一个timer callback指得是一个经过setTimeout或者setInterval函数设置的回调函数。

I/O callback阶段

如上文所言,这个阶段主要执行大部分I/O事件的回调,包括一些为操做系统执行的回调。例如一个TCP链接生错误时,系统须要执行回调来得到这个错误的报告。

4.process.nextTick,setTimeout与setImmediate的区别与使用场景

在node中有三个经常使用的用来推迟任务执行的方法:process.nextTick,setTimeout(setInterval与之相同)与setImmediate

这三者间存在着一些很是不一样的区别:

process.nextTick()

尽管没有说起,可是实际上node中存在着一个特殊的队列,即nextTick queue。这个队列中的回调执行虽然没有被表示为一个阶段,当时这些事件却会在每个阶段执行完毕准备进入下一个阶段时优先执行。当事件循环准备进入下一个阶段以前,会先检查nextTick queue中是否有任务,若是有,那么会先清空这个队列。与执行poll queue中的任务不一样的是,这个操做在队列清空前是不会中止的。这也就意味着,错误的使用process.nextTick()方法会致使node进入一个死循环。。直到内存泄漏。

那么合适使用这个方法比较合适呢?下面有一个例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

这个例子中当,当listen方法被调用时,除非端口被占用,不然会马上绑定在对应的端口上。这意味着此时这个端口能够马上触发listening事件并执行其回调。然而,这时候on('listening)尚未将callback设置好,天然没有callback能够执行。为了不出现这种状况,node会在listen事件中使用process.nextTick()方法,确保事件在回调函数绑定后被触发。

setTimeout()和setImmediate()

在三个方法中,这两个方法最容易被弄混。实际上,某些状况下这两个方法的表现也很是类似。然而实际上,这两个方法的意义却大为不一样。

setTimeout()方法是定义一个回调,而且但愿这个回调在咱们所指定的时间间隔后第一时间去执行。注意这个“第一时间执行”,这意味着,受到操做系统和当前执行任务的诸多影响,该回调并不会在咱们预期的时间间隔后精准的执行。执行的时间存在必定的延迟和偏差,这是不可避免的。node会在能够执行timer回调的第一时间去执行你所设定的任务。

setImmediate()方法从意义上将是马上执行的意思,可是实际上它倒是在一个固定的阶段才会执行回调,即poll阶段以后。有趣的是,这个名字的意义和以前提到过的process.nextTick()方法才是最匹配的。node的开发者们也清楚这两个方法的命名上存在必定的混淆,他们表示不会把这两个方法的名字调换过来---由于有大量的node程序使用着这两个方法,调换命名所带来的好处与它的影响相比不值一提。

setTimeout()和不设置时间间隔的setImmediate()表现上及其类似。猜猜下面这段代码的结果是什么?

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

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

实际上,答案是不必定。没错,就连node的开发者都没法准确的判断这二者的顺序谁前谁后。这取决于这段代码的运行环境。运行环境中的各类复杂的状况会致使在同步队列里两个方法的顺序随机决定。可是,在一种状况下能够准确判断两个方法回调的执行顺序,那就是在一个I/O事件的回调中。下面这段代码的顺序永远是固定的:

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});

答案永远是:

immediate
timeout

由于在I/O事件的回调中,setImmediate方法的回调永远在timer的回调前执行。

尾声

javascrit的事件循环是这门语言中很是重要且基础的概念。清楚的了解了事件循环的执行顺序和每个阶段的特色,可使咱们对一段异步代码的执行顺序有一个清晰的认识,从而减小代码运行的不肯定性。合理的使用各类延迟事件的方法,有助于代码更好的按照其优先级去执行。这篇文章指望用最易理解的方式和语言准确描述事件循环这个复杂过程,但因为做者本身水平有限,文章中不免出现疏漏。若是您发现了文章中的一些问题,欢迎在留言中提出,我会尽可能回复这些评论,把错误更正。