# 执行 & 运行

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

# 关于 JavaScript

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

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

# 概念梳理

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

# 事件循环(Event Loop)

An image

什么是 Event Loop?

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

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)。具体的执行机制会在后面剖析。

An image
  • 主线程自上而下执行所有代码
  • 同步任务直接进入到主线程被执行,而异步任务则进入到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,分别注册了onError和onSuccess回调函数。
  • 主线程执行同步任务:console.log('这是一个同步任务');
  • 主线程任务执行完毕,看Event Queue是否有待执行的 task,这里是不断地检查,只要主线程的task queue没有任务执行了,主线程就一直在这等着
  • ajax 执行完毕,将回调函数pushEvent Queue。(步骤 3、4 没有先后顺序而言)
  • 主线程“终于”等到了Event Queue里有task可以执行了,执行对应的回调任务。
  • 如此往复。

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

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

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

MicroTask,为什么说上述不严谨我却还是强调所有的同步任务都是MacroTask呢,因为我们仅仅需要记住几个 MicroTask 即可,排除法!别的都是 MacroTask。MicroTask 包括:Process.nextTick、Promise.then catch finally(注意我不是说 Promise)、MutationObserver。

# 浏览器环境下的Event Loop

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

如开篇说到的,执行和运行是不同的,执行要区分环境。所以这里我们将Event Loop的介绍分为浏览器和 Node 两个环境下。

先放图镇楼!如果你已经理解了这张图的意思,那么恭喜你,你完全可以直接阅读 Node 环境下的Event Loop章节了!

An image

# 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);
}
An image

如上运行,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);
}
An image

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

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

An image

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)。

An image

一句话理解:一次事件循环回来后,开始去执行Task Queue中的 task,但是这里的task有优先级。所以优先执行MicroTask Queue中的 task,执行完后在执行MacroTaskQueue 中的 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 的例子。因为只要上面代码你懂了,无非就是公交再来一趟而已!

An image

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

# Node 环境下的 Event Loop

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

Event Loop 就是在libuv中实现的。所以关于 Node 的Event Loop学习,有两个官方途径可以学习:

  • libuv 文档

  • 官网的 What is the 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/Ocallback 会被延迟到这一轮的这一阶段执行
  • idle, prepare:仅内部使用
  • poll: 最为重要的阶段,执行I/Ocallback,在适当的条件下会阻塞在这个阶段
  • check: 执行setImmediate的 callback
  • close callbacks: 执行close事件的 callback,例如socket.on('close'[,fn])http.server.on('close, fn)

提示

上面六个阶段都不包括 process.nextTick()(下文会介绍)

An image

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

# 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 队列的任务。

An image

# 最后

来~期末考试了

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');
  });
});

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

# 参考文献

  • Tasks, microtasks, queues and schedules
  • libuv 文档
  • The Node.js Event Loop, Timers, and process.nextTick()
  • node 官网
  • async/await 在 chrome 环境和 node 环境的 执行结果不一致,求解?
  • 更快的异步函数和 Promise
  • 一次弄懂 Event Loop(彻底解决此类面试问题)
  • 这一次,彻底弄懂 JavaScript 执行机制
  • 不要混淆 nodejs 和浏览器中的 event loop

彻底吃透 JavaScript 的执行机制 (opens new window)