【THE LAST TIME】完全吃透 JavaScript 执行机制

前言

The last time, I have learned

【THE LAST TIME】一直是我想写的一个系列,旨在厚积薄发,重温前端。html

也是给本身的查缺补漏和技术分享。前端

欢迎你们多多评论指点吐槽。html5

系列文章均首发于公众号【全栈前端精选】,笔者文章集合详见 Nealyang/personalBlog。目录皆为暂定

执行 & 运行

首先咱们须要声明下,JavaScript 的执行和运行是两个不一样概念的,执行,通常依赖于环境,好比 node、浏览器、Ringo 等, JavaScript 在不一样环境下的执行机制可能并不相同。而今天咱们要讨论的 Event Loop 就是 JavaScript 的一种执行方式。因此下文咱们还会梳理 node 的执行方式。而运行呢,是指JavaScript 的解析引擎。这是统一的。node

关于 JavaScript

此篇文章中,这个小标题下,咱们只须要牢记一句话: JavaScript 是单线程语言 ,不管HTML5 里面 Web-Worker 仍是 node 里面的cluster都是“纸老虎”,并且 cluster 仍是进程管理相关。这里读者注意区分:进程和线程。git

既然 JavaScript 是单线程语言,那么就会存在一个问题,全部的代码都得一句一句的来执行。就像咱们在食堂排队打饭,必须一个一个排队点菜结帐。那些没有排到的,就得等着~github

概念梳理

在详解执行机制以前,先梳理一下 JavaScript 的一些基本概念,方便后面咱们说到的时候大伙儿内心有个印象和大概的轮廓。面试

事件循环(Event Loop)

什么是 Event Loop?ajax

其实这个概念仍是比较模糊的,由于他必须得结合着运行机制来解释。chrome

JavaScript 有一个主线程 main thread,和调用栈 call-stack 也称之为执行栈。全部的任务都会放到调用栈中等待主线程来执行。编程

暂且,咱们先理解为上图的大圈圈就是 Event Loop 吧!而且,这个圈圈,一直在转圈圈~ 也就是说,JavaScriptEvent Loop 是伴随着整个源码文件生命周期的,只要当前 JavaScript 在运行中,内部的这个循环就会不断地循环下去,去寻找 queue 里面能执行的 task

任务队列(task queue)

task,就是任务的意思,咱们这里理解为每个语句就是一个任务

console.log(1);
console.log(2);

如上语句,其实就是就能够理解为两个 task

queue 呢,就是FIFO的队列!

因此 Task Queue 就是承载任务的队列。而 JavaScriptEvent Loop 就是会不断地过来找这个 queue,问有没有 task 能够运行运行。

同步任务(SyncTask)、异步任务(AsyncTask)

同步任务说白了就是主线程来执行的时候当即就能执行的代码,好比:

console.log('this is THE LAST TIME');
console.log('Nealyang');

代码在执行到上述 console 的时候,就会当即在控制台上打印相应结果。

而所谓的异步任务就是主线程执行到这个 task 的时候,“_唉!你等会,我如今先不执行,等我 xxx 完了之后我再来等你执行_” 注意上述我说的是等你来执行。

说白了,异步任务就是你先去执行别的 task,等我这 xxx 完以后再往 Task Queue 里面塞一个 task 的同步任务来等待被执行

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

如上述代码,setTimeout 就是一个异步任务,主线程去执行的时候遇到 setTimeout 发现是一个异步任务,就先注册了一个异步的回调,而后接着执行下面的语句console.log(1),等上面的异步任务等待的时间到了之后,在执行console.log(2)。具体的执行机制会在后面剖析。

  • 主线程自上而下执行全部代码
  • 同步任务直接进入到主线程被执行,而异步任务则进入到 Event Table 并注册相对应的回调函数
  • 异步任务完成后,Event Table 会将这个函数移入 Event Queue
  • 主线程任务执行完了之后,会从Event Queue中读取任务,进入到主线程去执行。
  • 循环如上

上述动做不断循环,就是咱们所说的事件循环(Event Loop)。

小试牛刀

ajax({
    url:www.Nealyang.com,
    data:prams,
    success:() => {
        console.log('请求成功!');
    },
    error:()=>{
        console.log('请求失败~');
    }
})
console.log('这是一个同步任务');
  • ajax 请求首先进入到 Event Table ,分别注册了onErroronSuccess回调函数。
  • 主线程执行同步任务:console.log('这是一个同步任务');
  • 主线程任务执行完毕,看Event Queue是否有待执行的 task,这里是不断地检查,只要主线程的task queue没有任务执行了,主线程就一直在这等着
  • ajax 执行完毕,将回调函数pushEvent Queue。(步骤 三、4 没有前后顺序而言)
  • 主线程“终于”等到了Event Queue里有 task能够执行了,执行对应的回调任务。
  • 如此往复。

宏任务(MacroTask)、微任务(MicroTask)

JavaScript 的任务不只仅分为同步任务和异步任务,同时从另外一个维度,也分为了宏任务(MacroTask)和微任务(MicroTask)。

先说说 MacroTask,全部的同步任务代码都是MacroTask(这么说其实不是很严谨,下面解释),setTimeoutsetIntervalI/OUI Rendering 等都是宏任务。

MicroTask,为何说上述不严谨我却仍是强调全部的同步任务都是 MacroTask 呢,由于咱们仅仅须要记住几个 MicroTask 便可,排除法!别的都是 MacroTaskMicroTask 包括:Process.nextTickPromise.then catch finally(注意我不是说 Promise)、MutationObserver

浏览器环境下的 Event Loop

当咱们梳理完哪些是 MicroTask ,除了那些别的都是 MacroTask 后,哪些是同步任务,哪些又是异步任务后,这里就应该完全的梳理下JavaScript 的执行机制了。

如开篇说到的,执行和运行是不一样的,执行要区分环境。因此这里咱们将 Event Loop 的介绍分为浏览器和 Node 两个环境下。

先放图镇楼!若是你已经理解了这张图的意思,那么恭喜你,你彻底能够直接阅读 Node 环境下的 Event Loop 章节了!

setTimeout、setInterval

setTimeout

setTimeout 就是等多长时间来执行这个回调函数。setInterval 就是每隔多长时间来执行这个回调。

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

setTimeout(()=>{
  console.log(new Date().getTime()-startTime);
},1000);

如上代码,顾名思义,就是等 1s 后再去执行 console。放到浏览器下去执行,OK,如你所愿就是如此。

可是此次咱们在探讨 JavaScript 的执行机制,因此这里咱们得探讨下以下代码:

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

console.log({startTime})

setTimeout(()=>{
  console.log(`开始执行回调的相隔时差:${new Date().getTime()-startTime}`);
},1000);

for(let i = 0;i<40000;i++){
  console.log(1)
}

如上运行,setTimeout 的回调函数等到 4.7s 之后才执行!而这时候,咱们把 setTimeout 的 1s 延迟给删了:

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

console.log({startTime})

setTimeout(()=>{
  console.log(`开始执行回调的相隔时差:${new Date().getTime()-startTime}`);
},0);

for(let i = 0;i<40000;i++){
  console.log(1)
}

结果依然是等到 4.7s 后才执行setTimeout 的回调。貌似 setTimeout 后面的延迟并无产生任何效果!

其实这么说,又应该回到上面的那张 JavaScript 执行的流程图了。

setTimeout这里就是简单的异步,咱们经过上面的图来分析上述代码的一步一步执行状况

  • 首先 JavaScript 自上而下执行代码
  • 遇到遇到赋值语句、以及第一个 console.log({startTime}) 分别做为一个 task,压入到当即执行栈中被执行。
  • 遇到 setTImeout 是一个异步任务,则注册相应回调函数。(异步函数告诉你,js 你先别急,等 1s 后我再将回调函数:console.log(xxx)放到 Task Queue 中)
  • OK,这时候 JavaScript 则接着往下走,遇到了 40000 个 for 循环的 task,没办法,1s 后都还没执行完。其实这个时候上述的回调已经在Task Queue 中了。
  • 等全部的当即执行栈中的 task 都执行完了,在回头看 Task Queue 中的任务,发现异步的回调 task 已经在里面了,因此接着执行。

打个比方

其实上述的不只仅是 timeout,而是任何异步,好比网络请求等。

就比如,我六点钟下班了,能够安排下本身的活动了!

而后收拾电脑(同步任务)、收拾书包(同步任务)、给女友打电话说出来吃饭吧(必然是异步任务),而后女友说你等会,我先化个妆,等我画好了call你。

那我不能干等着呀,就接着作别的事情,好比那我就在改个 bug 吧,你好了通知我。结果等她一个小时后说我化好妆了,咱们出去吃饭吧。不行!我 bug 尚未解决掉呢?你等会。。。。其实这个时候你的一小时化妆仍是 5 分钟化妆都已经毫无心义了。。。由于哥哥这会没空~~

若是我 bug 在半个小时就解决完了,没别的任务须要执行了,那么就在这等着呀!必须等着!随时待命!。而后女友来电话了,我化完妆了,咱们出去吃饭吧,那么恰好,咱们在你的完成了请求或者 timeout 时间到了后我恰好闲着,那么我必须当即执行了。

setInterval

说完了 setTimeout,固然不能错过他的孪生兄弟:setInterval。对于执行顺序来讲,setInterval会每隔指定的时间将注册的函数置入 Task Queue,若是前面的任务耗时过久,那么一样须要等待。

这里须要说的是,对于 setInterval(fn,ms) 来讲,咱们制定没 xx ms执行一次 fn,实际上是没 xx ms,会有一个fn 进入到 Task Queue 中。一旦 setInterval 的回调函数fn执行时间超过了xx ms,那么就彻底看不出来有时间间隔了。 仔细回味回味,是否是那么回事?

Promise

关于 Promise 的用法,这里就不过过多介绍了,后面会在写《【THE LAST TIME】完全吃透 JavaScript 异步》 一文的时候详细介绍。这里咱们只说 JavaScript 的执行机制。

如上所说,promise.thencatchfinally 是属于 MicroTask。这里主要是异步的区分。展开说明以前,咱们结合上述说的,再来“扭曲”梳理一下。

为了不初学者这时候脑子有点混乱,咱们暂时忘掉 JavaScript 异步任务! 咱们暂且称之为待会再执行的同步任务。

有了如上约束后,咱们能够说,JavaScript 从一开始就自上而下的执行每个语句(Task),这时候只能遇到立马就要执行的任务和待会再执行的任务。对于那待会再执行的任务等到能执行了,也不会当即执行,你得等js 执行完这一趟才行

再打个比方

就像作公交车同样,公交车不等人呀,公交车路线上有人就会停(农村公交!么得站牌),可是等公交车来,你跟司机说,我肚子疼要拉x~这时候公交不会等你。你只能拉完之后等公交下一趟再来(大山里!一个路线就一趟车)。

OK!你拉完了。。。等公交,公交也很快到了!可是,你不能立立刻车,由于这时候前面有个孕妇!有个老人!还有熊孩子,你必须得让他们先上车,而后你才能上车!

而这些 孕妇、老人、熊孩子所组成的就是传说中的 MicroTask Queue,并且,就在你和你的同事、朋友就必须在他们后面上车。

这里咱们没有异步的概念,只有一样的一次循环回来,有了两种队伍,一种优先上车的队伍叫作MicroTask Queue,而你和你的同事这帮壮汉组成的队伍就是宏队伍(MacroTask Queue)。

一句话理解:一次事件循环回来后,开始去执行 Task Queue 中的 task,可是这里的 task优先级。因此优先执行 MicroTask Queue 中的 task
,执行完后在执行MacroTask Queue 中的 task

小试牛刀

理论都扯完了,也不知道你懂没懂。来,期中考试了!

console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});
不必搞个 setTimeout 有加个 Promise,Promise 里面再整个 setTimeout 的例子。由于只要上面代码你懂了,无非就是公交再来一趟而已!

若是说了这么多,仍是没能理解上图,那么公众号内回复【1】,手摸手指导!

Node 环境下的 Event Loop

Node中的Event Loop是基于libuv实现的,而libuv是 Node 的新跨平台抽象层,libuv使用异步,事件驱动的编程方式,核心是提供i/o的事件循环和异步回调。libuvAPI包含有时间,非阻塞的网络,异步文件操做,子进程等等。

Event Loop就是在libuv中实现的。因此关于 Node 的 Event Loop学习,有两个官方途径能够学习:

在学习 Node 环境下的 Event Loop 以前呢,咱们首先要明确执行环境,Node 和浏览器的Event Loop是两个有明确区分的事物,不能混为一谈。nodejs的event是基于libuv,而浏览器的event loop则在html5的规范中明肯定义。

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

Node 的 Event Loop 分为 6 个阶段:

  • timers:执行setTimeout()setInterval()中到期的callback。
  • pending callback: 上一轮循环中有少数的I/O callback会被延迟到这一轮的这一阶段执行
  • idle, prepare:仅内部使用
  • poll: 最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
  • check: 执行setImmediate的callback
  • close callbacks: 执行close事件的callback,例如socket.on('close'[,fn])http.server.on('close, fn)
上面六个阶段都不包括 process.nextTick()(下文会介绍)

总体的执行机制如上图所示,下面咱们具体展开每个阶段的说明

timers 阶段

timers 阶段会执行 setTimeoutsetInterval 回调,而且是由 poll 阶段控制的。

在 timers 阶段其实使用一个最小堆而不是队列来保存全部的元素,其实也能够理解,由于timeout的callback是按照超时时间的顺序来调用的,并非先进先出的队列逻辑)。而为何 timer 阶段在第一个执行阶梯上其实也不难理解。在 Node 中定时器指定的时间也是不许确的,而这样,就能尽量的准确了,让其回调函数尽快执行。

如下是官网给出的例子:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

当进入事件循环时,它有一个空队列(fs.readFile()还没有完成),所以定时器将等待剩余毫秒数,当到达95ms时,fs.readFile()完成读取文件而且其完成须要10毫秒的回调被添加到轮询队列并执行。

当回调结束时,队列中再也不有回调,所以事件循环将看到已达到最快定时器的阈值,而后回到timers阶段以执行定时器的回调。
在此示例中,您将看到正在调度的计时器与正在执行的回调之间的总延迟将为105毫秒。

pending callbacks 阶段

pending callbacks 阶段实际上是 I/O 的 callbacks 阶段。好比一些 TCP 的 error 回调等。

举个栗子:若是TCP socket ECONNREFUSED在尝试connectreceives,则某些* nix系统但愿等待报告错误。 这将在pending callbacks阶段执行。

poll 阶段

poll 阶段主要有两个功能:

  • 执行 I/O 回调
  • 处理 poll 队列(poll queue)中的事件

当时Event Loop 进入到 poll 阶段而且 timers 阶段没有任何可执行的 task 的时候(也就是没有定时器回调),将会有如下两种状况

  • 若是 poll queue 非空,则 Event Loop就会执行他们,知道为空或者达到system-dependent(系统相关限制)
  • 若是 poll queue 为空,则会发生如下一种状况

    • 若是setImmediate()有回调须要执行,则会当即进入到 check 阶段
    • 相反,若是没有setImmediate()须要执行,则 poll 阶段将等待 callback 被添加到队列中再当即执行,这也是为何咱们说 poll 阶段可能会阻塞的缘由。

一旦 poll queue 为空,Event Loop就回去检查timer 阶段的任务。若是有的话,则会回到 timer 阶段执行回调。

check 阶段

check 阶段在 poll 阶段以后,setImmediate()的回调会被加入check队列中,他是一个使用libuv API 的特殊的计数器。

一般在代码执行的时候,Event Loop 最终会到达 poll 阶段,而后等待传入的连接或者请求等,可是若是已经指定了setImmediate()而且这时候 poll 阶段已经空闲的时候,则 poll 阶段将会被停止而后开始 check 阶段的执行。

close callbacks 阶段

若是一个 socket 或者事件处理函数忽然关闭/中断(好比:socket.destroy()),则这个阶段就会发生 close 的回调执行。不然他会经过 process.nextTick() 发出。

setImmediate() vs setTimeout()

setImmediate()setTimeout()很是的类似,区别取决于谁调用了它。

  • setImmediate在 poll 阶段后执行,即check 阶段
  • setTimeout 在 poll 空闲时且设定时间到达的时候执行,在 timer 阶段

计时器的执行顺序将根据调用它们的上下文而有所不一样。 若是二者都是从主模块中调用的,则时序将受到进程性能的限制。

例如,若是咱们运行如下不在I / O周期(即主模块)内的脚本,则两个计时器的执行顺序是不肯定的,由于它受进程性能的约束:

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

setImmediate(() => {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

若是在一个I/O 周期内移动这两个调用,则始终首先执行当即回调:

// timeout_vs_immediate.js
const fs = require('fs');

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

$ node timeout_vs_immediate.js
immediate
timeout

因此与setTimeout()相比,使用setImmediate()的主要优势是,若是在I / O周期内安排了任何计时器,则setImmediate()将始终在任何计时器以前执行,而与存在多少计时器无关。

nextTick queue

可能你已经注意到process.nextTick()并未显示在图中,即便它是异步API的一部分。 因此他拥有一个本身的队列:nextTickQueue

这是由于process.nextTick()从技术上讲不是Event Loop的一部分。 相反,不管当前事件循环的当前阶段如何,都将在当前操做完成以后处理nextTickQueue

若是存在 nextTickQueue,就会清空队列中的全部回调函数,而且优先于其余 microtask 执行。

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1

process.nextTick() vs setImmediate()

从使用者角度而言,这两个名称很是的容易让人感受到困惑。

  • process.nextTick()在同一阶段当即触发
  • setImmediate()在事件循环的如下迭代或“tick”中触发

貌似这两个名称应该呼唤下!的确~官方也这么认为。可是他们说这是历史包袱,已经不会更改了。

这里仍是建议你们尽量使用setImmediate。由于更加的让程序可控容易推理。

至于为何仍是须要 process.nextTick,存在即合理。这里建议你们阅读官方文档:why-use-process-nexttick

Node与浏览器的 Event Loop 差别

一句话总结其中:浏览器环境下,microtask的任务队列是每一个macrotask执行完以后执行。而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

上图来自浪里行舟

最后

来~期末考试了

console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

评论区留下你的答案吧~~老铁!

参考文献

学习交流

关注公众号: 【全栈前端精选】 每日获取好文推荐。

公众号内回复 【1】,加入全栈前端学习群,一块儿交流。