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

笔者之前面试的时候常常遇到写一堆setTimeout,setImmediate来问哪一个先执行。本文主要就是来说这个问题的,可是不是简单的讲讲哪一个先,哪一个后。笼统的知道setImmediatesetTimeout(fn, 0)先执行是不够的,由于有些状况下setTimeout(fn, 0)是会比setImmediate先执行的。要完全搞明白这个问题,咱们须要系统的学习JS的异步机制和底层原理。本文就会从异步基本概念出发,一直讲到Event Loop的底层原理,让你完全搞懂setTimeout,setImmediatePromise, process.nextTick谁先谁后这一类问题。javascript

同步和异步

同步异步简单理解就是,同步的代码都是按照书写顺序执行的,异步的代码可能跟书写顺序不同,写在后面的可能先执行。下面来看个例子:html

const syncFunc = () => {
  const time = new Date().getTime();
  while(true) {
    if(new Date().getTime() - time > 2000) {
      break;
    }
  }
  console.log(2);
}

console.log(1);
syncFunc();
console.log(3);

上述代码会先打印出1,而后调用syncFuncsyncFunc里面while循环会运行2秒,而后打印出2,最后打印出3。因此这里代码的执行顺序跟咱们的书写顺序是一致,他是同步代码:前端

image-20200320144654281

再来看个异步例子:java

const asyncFunc = () => {
  setTimeout(() => {
    console.log(2);
  }, 2000);
}

console.log(1);
asyncFunc();
console.log(3);

上述代码的输出是:node

image-20200320145012565

能够看到咱们中间调用的asyncFunc里面的2倒是最后输出的,这是由于setTimeout是一个异步方法。他的做用是设置一个定时器,等定时器时间到了再执行回调里面的代码。因此异步就至关于作一件事,可是并非立刻作,而是你先给别人打了个招呼,说xxx条件知足的时候就干什么什么。就像你晚上睡觉前在手机上设置了一个次日早上7天的闹钟,就至关于给了手机一个异步事件,触发条件是时间到达早上7点。使用异步的好处是你只须要设置好异步的触发条件就能够去干别的事情了,因此异步不会阻塞主干上事件的执行。特别是对于JS这种只有一个线程的语言,若是都像咱们第一个例子那样去while(true),那浏览器就只有一直卡死了,只有等这个循环运行完才会有响应git

JS异步是怎么实现的

咱们都知道JS是单线程的,那单线程是怎么实现异步的呢?事实上所谓的"JS是单线程的"只是指JS的主运行线程只有一个,而不是整个运行环境都是单线程。JS的运行环境主要是浏览器,以你们都很熟悉的Chrome的内核为例,他不只是多线程的,并且是多进程的:github

image-20200320151227013

上图只是一个归纳分类,意思是Chrome有这几类的进程和线程,并非每种只有一个,好比渲染进程就有多个,每一个选项卡都有本身的渲染进程。有时候咱们使用Chrome会遇到某个选项卡崩溃或者没有响应的状况,这个选项卡对应的渲染进程可能就崩溃了,可是其余选项卡并无用这个渲染进程,他们有本身的渲染进程,因此其余选项卡并不会受影响。这也是Chrome单个页面崩溃并不会致使浏览器崩溃的缘由,而不是像老IE那样,一个页面卡了致使整个浏览器都卡。面试

对于前端工程师来讲,主要关心的仍是渲染进程,下面来分别看下里面每一个线程是作什么的。ajax

GUI线程

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

JS引擎线程

这个线程就是负责执行JS的主线程,前面说的"JS是单线程的"就是指的这个线程。大名鼎鼎的Chrome V8引擎就是在这个线程运行的。须要注意的是,这个线程跟GUI线程是互斥的。互斥的缘由是JS也能够操做DOM,若是JS线程和GUI线程同时操做DOM,结果就混乱了,不知道到底渲染哪一个结果。这带来的后果就是若是JS长时间运行,GUI线程就不能执行,整个页面就感受卡死了。因此咱们最开始例子的while(true)这样长时间的同步代码在真正开发时是绝对不容许的

定时器线程

前面异步例子的setTimeout其实就运行在这里,他跟JS主线程根本不在同一个地方,因此“单线程的JS”可以实现异步。JS的定时器方法还有setInterval,也是在这个线程。

事件触发线程

定时器线程其实只是一个计时的做用,他并不会真正执行时间到了的回调,真正执行这个回调的仍是JS主线程。因此当时间到了定时器线程会将这个回调事件给到事件触发线程,而后事件触发线程将它加到事件队列里面去。最终JS主线程从事件队列取出这个回调执行。事件触发线程不只会将定时器事件放入任务队列,其余知足条件的事件也是他负责放进任务队列。

异步HTTP请求线程

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

因此JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API知足回调条件时,对应的线程又经过事件触发线程将这个事件放入任务队列,而后主线程从任务队列取出事件继续执行。这个流程咱们屡次提到了任务队列,这其实就是Event Loop,下面咱们详细来说解下。

Event Loop

所谓Event Loop,就是事件循环,其实就是JS管理事件执行的一个流程,具体的管理办法由他具体的运行环境肯定。目前JS的主要运行环境有两个,浏览器和Node.js。这两个环境的Event Loop还有点区别,咱们会分开来说。

浏览器的Event Loop

事件循环就是一个循环,是各个异步线程用来通信和协同执行的机制。各个线程为了交换消息,还有一个公用的数据区,这就是事件队列。各个异步线程执行完后,经过事件触发线程将回调事件放到事件队列,主线程每次干完手上的活儿就来看看这个队列有没有新活儿,有的话就取出来执行。画成一个流程图就是这样:

image-20200320161732238

流程讲解以下:

  1. 主线程每次执行时,先看看要执行的是同步任务,仍是异步的API
  2. 同步任务就继续执行,一直执行完
  3. 遇到异步API就将它交给对应的异步线程,本身继续执行同步任务
  4. 异步线程执行异步API,执行完后,将异步回调事件放入事件队列上
  5. 主线程手上的同步任务干完后就来事件队列看看有没有任务
  6. 主线程发现事件队列有任务,就取出里面的任务执行
  7. 主线程不断循环上述流程

定时器不许

Event Loop的这个流程里面其实仍是隐藏了一些坑的,最典型的问题就是老是先执行同步任务,而后再执行事件队列里面的回调。这个特性就直接影响了定时器的执行,咱们想一想咱们开始那个2秒定时器的执行流程:

  1. 主线程执行同步代码
  2. 遇到setTimeout,将它交给定时器线程
  3. 定时器线程开始计时,2秒到了通知事件触发线程
  4. 事件触发线程将定时器回调放入事件队列,异步流程到此结束
  5. 主线程若是有空,将定时器回调拿出来执行,若是没空这个回调就一直放在队列里。

上述流程咱们能够看出,若是主线程长时间被阻塞,定时器回调就没机会执行,即便执行了,那时间也不许了,咱们将开头那两个例子结合起来就能够看出这个效果:

const syncFunc = (startTime) => {
  const time = new Date().getTime();
  while(true) {
    if(new Date().getTime() - time > 5000) {
      break;
    }
  }
  const offset = new Date().getTime() - startTime;
  console.log(`syncFunc run, time offset: ${offset}`);
}

const asyncFunc = (startTime) => {
  setTimeout(() => {
    const offset = new Date().getTime() - startTime;
    console.log(`asyncFunc run, time offset: ${offset}`);
  }, 2000);
}

const startTime = new Date().getTime();

asyncFunc(startTime);

syncFunc(startTime);

执行结果以下:

image-20200320163640760

经过结果能够看出,虽然咱们先调用的asyncFunc,虽然asyncFunc写的是2秒后执行,可是syncFunc的执行时间太长,达到了5秒,asyncFunc虽然在2秒的时候就已经进入了事件队列,可是主线程一直在执行同步代码,一直没空,因此也要等到5秒后,同步代码执行完毕才有机会执行这个定时器回调。因此再次强调,写代码时必定不要长时间占用主线程

引入微任务

前面的流程图我为了便于理解,简化了事件队列,其实事件队列里面的事件还能够分两类:宏任务和微任务。微任务拥有更高的优先级,当事件循环遍历队列时,先检查微任务队列,若是里面有任务,就所有拿来执行,执行完以后再执行一个宏任务。执行每一个宏任务以前都要检查下微任务队列是否有任务,若是有,优先执行微任务队列。因此完整的流程图以下:

image-20200322201434386

上图须要注意如下几点:

  1. 一个Event Loop能够有一个或多个事件队列,可是只有一个微任务队列。
  2. 微任务队列所有执行完会从新渲染一次
  3. 每一个宏任务执行完都会从新渲染一次
  4. requestAnimationFrame处于渲染阶段,不在微任务队列,也不在宏任务队列

因此想要知道一个异步API在哪一个阶段执行,咱们得知道他是宏任务仍是微任务。

常见宏任务有:

  1. script (能够理解为外层同步代码)
  2. setTimeout/setInterval
  3. setImmediate(Node.js)
  4. I/O
  5. UI事件
  6. postMessage

常见微任务有:

  1. Promise
  2. process.nextTick(Node.js)
  3. Object.observe
  4. MutaionObserver

上面这些事件类型中要注意Promise,他是微任务,也就是说他会在定时器前面运行,咱们来看个例子:

console.log('1');
setTimeout(() => {
  console.log('2');
},0);
Promise.resolve().then(() => {
  console.log('5');
})
new Promise((resolve) => {
  console.log('3');
  resolve();
}).then(() => {
  console.log('4');
})

上述代码的输出是1,3,5,4,2。由于:

  1. 先输出1,这个没什么说的,同步代码最早执行
  2. console.log('2');setTimeout里面,setTimeout是宏任务,“2”进入宏任务队列
  3. console.log('5');Promise.then里面,进入微任务队列
  4. console.log('3');在Promise构造函数的参数里面,这实际上是同步代码,直接输出
  5. console.log('4');在then里面,他会进入微任务队列,检查事件队列时先执行微任务
  6. 同步代码运行结果是“1,3”
  7. 而后检查微任务队列,输出“5,4”
  8. 最后执行宏任务队列,输出“2”

Node.js的Event Loop

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

image-20200322203318743

  1. timers: 执行setTimeoutsetInterval的回调
  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或者定时器,若是没有的话,程序就关闭退出了。咱们的直观感觉就是,若是一个Node程序只有同步代码,你在控制台运行完后,他就本身退出了。

还有个须要注意的是poll阶段,他后面并不必定每次都是check阶段,poll队列执行完后,若是没有setImmediate可是有定时器到期,他会绕回去执行定时器阶段:

image-20200322205308151

setImmediatesetTimeout

上面的这个流程说简单点就是在一个异步流程里,setImmediate会比定时器先执行,咱们写点代码来试试:

console.log('outer');

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

上述代码运行以下:

image-20200322210304757

和咱们前面讲的同样,setImmediate先执行了。咱们来理一下这个流程:

  1. 外层是一个setTimeout,因此执行他的回调的时候已经在timers阶段了
  2. 处理里面的setTimeout,由于本次循环的timers正在执行,因此他的回调其实加到了下个timers阶段
  3. 处理里面的setImmediate,将它的回调加入check阶段的队列
  4. 外层timers阶段执行完,进入pending callbacksidle, preparepoll,这几个队列都是空的,因此继续往下
  5. 到了check阶段,发现了setImmediate的回调,拿出来执行
  6. 而后是close callbacks,队列是空的,跳过
  7. 又是timers阶段,执行咱们的console

可是请注意咱们上面console.log('setTimeout')console.log('setImmediate')都包在了一个setTimeout里面,若是直接写在最外层会怎么样呢?代码改写以下:

console.log('outer');

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

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

咱们来运行下看看效果:

image-20200322214105295

好像是setTimeout先输出来,咱们多运行几回看看:

image-20200322214148090

怎么setImmediate又先出来了,这代码是见鬼了仍是啥?这个世界上是没有鬼怪的,因此事情都有缘由的,咱们顺着以前的Event Loop再来理一下。在理以前,须要告诉你们一件事情,node.js里面setTimeout(fn, 0)会被强制改成setTimeout(fn, 1),这在官方文档中有说明。(说到这里顺便提下,HTML 5里面setTimeout最小的时间限制是4ms)。原理咱们都有了,咱们来理一下流程:

  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阶段的时候。像咱们第一个例子那样,由于已经在timers阶段,因此里面的setTimeout只能等下个循环了,因此setImmediate确定先执行。同理的还有其余poll阶段的API也是这样的,好比:

var fs = require('fs')

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

这里setTimeoutsetImmediatereadFile的回调里面,因为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

process.nextTick()

process.nextTick()是一个特殊的异步API,他不属于任何的Event Loop阶段。事实上Node在遇到这个API时,Event Loop根本就不会继续进行,会立刻停下来执行process.nextTick(),这个执行完后才会继续Event Loop。咱们写个例子来看下:

var fs = require('fs')

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

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

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

这段代码的打印以下:

image-20200322221221927

咱们仍是来理一下流程:

  1. 咱们代码基本都在readFile回调里面,他本身执行时,已经在poll阶段
  2. 遇到setTimeout(fn, 0),实际上是setTimeout(fn, 1),塞入后面的timers阶段
  3. 遇到setImmediate,塞入后面的check阶段
  4. 遇到nextTick,立马执行,输出'nextTick 1'
  5. 到了check阶段,输出'setImmediate',又遇到个nextTick,立马输出'nextTick 2'
  6. 到了下个timers阶段,输出'setTimeout'

这种机制其实相似于咱们前面讲的微任务,可是并不彻底同样,好比同时有nextTickPromise的时候,确定是nextTick先执行,缘由是nextTick的队列比Promise队列优先级更高。来看个例子:

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

代码运行结果以下:

image-20200323094907234

总结

本文从异步基本概念出发一直讲到了浏览器和Node.js的Event Loop,如今咱们再来总结一下:

  1. JS所谓的“单线程”只是指主线程只有一个,并非整个运行环境都是单线程
  2. JS的异步靠底层的多线程实现
  3. 不一样的异步API对应不一样的实现线程
  4. 异步线程与主线程通信靠的是Event Loop
  5. 异步线程完成任务后将其放入任务队列
  6. 主线程不断轮询任务队列,拿出任务执行
  7. 任务队列有宏任务队列和微任务队列的区别
  8. 微任务队列的优先级更高,全部微任务处理完后才会处理宏任务
  9. Promise是微任务
  10. Node.js的Event Loop跟浏览器的Event Loop不同,他是分阶段的
  11. setImmediatesetTimeout(fn, 0)哪一个回调先执行,须要看他们自己在哪一个阶段注册的,若是在定时器回调或者I/O回调里面,setImmediate确定先执行。若是在最外层或者setImmediate回调里面,哪一个先执行取决于当时机器情况。
  12. process.nextTick不在Event Loop的任何阶段,他是一个特殊API,他会当即执行,而后才会继续执行Event Loop

文章的最后,感谢你花费宝贵的时间阅读本文,若是本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是做者持续创做的动力。

做者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges

相关文章
相关标签/搜索