JavaScript 的单线程
我们经常说 JavaScript 是单线程执行的,这里指的是一个进程内只有一个主线程。
多进程,指的是在同一时间里,同一个计算机系统中允许两个及以上的进程处于运行状态。例如我们可以在编写代码的同时使用软件听音乐。
以浏览器为例,我们在打开一个 Tab 页面时,已经创建了一个进程,在这一个进程中可以包含多个线程,例如 HTTP 请求线程、JS 引擎线程等等。
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
为了避免复杂性,JavaScript 将单线程作为语言的核心。JS 脚本可以创建出多个子进程,并由主线程控制,且不能够操作 DOM。
Event Loop 是 JavaScript 的执行机制。JavaScript 在不同的环境下,例如 Node、浏览器等等,其执行的方式实际上也是不同的。
任务
划分
所有的任务可以分为同步任务与异步任务。所谓同步任务指的是主进程上排队执行的任务,当一个任务执行完毕后会执行下一个。而异步任务指的是,它不进入主进程,而是在任务队列上,当队列通知进程某个异步可以执行了,该任务就进入主线程执行。
队列
队列是遵循先进先出 (FIFO) 原则的有序集合,队列在尾部添加新元素,并在顶部移除元素,最新添加的元素必须排在队列的末尾。在计算机科学中,最常见的例子就是打印队列。JavaScript 在运行中有一个异步队列,其中的任务按照先后顺序执行,排在头部的任务会率先执行,该队列每次只执行一个任务。当我们的执行栈为空时,JavaScript 引擎便会去检查任务队列,如果任务队列不为空,则将第一个任务压如执行栈中执行。
浏览器中的 Event Loop
任务划分
浏览器端的事件循环异步队列分为两种:宏任务(macro-task)队列和微任务(micro-task)队列。常见的任务如下:
- 宏任务:script 代码、setTimeout、setInterval、I/O、UI rendering
- 微任务:Promise.then()、MutationObserver 等
事件循环过程

- 首先,全局上下文被推入执行栈,同步代码被执行。此时微队列为空。
- 同步代码执行,在执行中会判断是同步任务还是异步任务,通过 API 的调用也会产生新的宏任务与微任务,它们会分别推入各自的队列中去。
- 主线程内的任务执行完毕后。会去检查微任务队列是否为空。
- 开始执行完微任务,去读取宏任务队列里排在最前面的任务。这里与执行宏任务的区别在于,微任务是一整队被执行完,而宏任务是一次一个地执行。执行宏任务的过程中会还会遇到宏微任务,依次将其加入各自的任务队列。
- 以上的过程会不断地重复,直到两个队列都被清空,这个过程就是我们所谓的事件循环。
总结起来,当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。
我们来看一个例子:
setTimeout(function() {
console.log('setTimeout')
})
new Promise(function(resolve) {
console.log('promise')
resolve()
}).then(function() {
console.log('then')
})
console.log('console')
- 首先整段代码作为宏任务进入主线程
- 遇到
setTimeout,将其回调函数注册并推入宏任务队列 - 遇到
new Promise直接执行,打印出promise,再遇到then,将其回调推入微任务队列中。 - 遇到
console.log,打印出console - 此时第一次循环结束,首先检查微任务队列,发现
then,执行后打印出then - 微任务队列为空后,检查宏任务队列,将其第一个任务推入主线程,即
setTimeout的回调函数,执行后打印出setTimeout - 此时两个队列都为空,代码执行完毕。
async/await
async/await 语法是 ES7 中的生成器的语法糖,我们可以将其转换为 Promise 的形式来看。
首先在 async 函数中,await 之前的代码可以看做是同步的,可以直接按顺序执行,而 await 后面的函数,可以被转化为Promise.resolve(fn())的形式,它也属于同步任务。await 后面的代码,则可以看做是 then()方法的回调函数。通过这样的转换,我们就可以轻松地理清它们的执行顺序。
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function() {
console.log('setTimeout')
}, 0)
async1()
console.log('end')
- 首先,打印出
start - 遇到
setTimeout,将其回调函数注册并推入宏任务队列 - 遇到
async1,首先打印出async1 start,接着执行同步任务async2,打印出async2,await后面的代码推入微任务队列 - 遇到
console.log,打印出end - 此时先去检查微任务队列,打印出
async1 end - 检查宏任务队列,打印出
setTimeout - 两个任务队列为空,执行完毕
Node 中的 Event Loop
Node11 中的 Event Loop 运行原理发生变化,行为与浏览器保持一致。因此下文讨论的是针对 11 版本以下。
Node 中的 Event Loop 和浏览器中的是完全不相同的东西。Node.js 采用 V8 作为 js 的解析引擎,I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API。

六个阶段

当 Event Loop 进入某个阶段后,会执行该阶段特定的(任意)操作,然后才会执行这个阶段的队列里的回调。当队列被执行完,或者执行的回调数量达到上限后,event loop 会进入下个阶段。
timers
一个 timer(setTimeout、setInterval)指定一个下限时间,而不会准确时间,达到这个下限时间后会执行回调。在达到指定的时候后,timers 会尽早得去执行,但是系统调度或者其他回调执行可能会延迟它们。这里的延迟后面会提到。
timer 阶段执行 setTimeout 和 setInterval 回调,由 poll 阶段控制。
I/O callbacks
执行一些系统的回调,如 TCP 连接发生错误。
idel,prepare
系统内部一些调用,仅限 Node 内部使用。
poll
这个阶段会有两个功能:
- 执行下限时间已经到达的 timers 的回调
- 处理 poll 队列里的事件
当没有 timers,会发生以下两种情况之一。
- poll 队列不为空的时候,事件循环肯定是先遍历队列并同步执行回调,直到队列清空或执行回调数达到系统上限。
- poll 队列为空的时候,这里有两种情况。
- 如果代码已经被 setImmediate()设定了回调,那么事件循环直接结束 poll 阶段进入 check 阶段来执行 check 队列里的回调。
- 如果代码没有被设定 setImmediate()设定回调:
- 如果有被设定的 timers,那么此时事件循环会检查 timers,如果有一个或多个 timers 下限时间已经到达,那么事件循环将绕回 timers 阶段,并执行 timers 的有效回调队列。
- 如果没有被设定 timers,这个时候事件循环是阻塞在 poll 阶段等待回调被加入 poll 队列。
check
这个阶段允许在 poll 阶段结束后立即执行回调。如果 poll 阶段空闲,并且有被 setImmediate()设定的回调,那么事件循环直接跳到 check 执行而不是阻塞在 poll 阶段等待回调被加入。
close callbacks
如果一个 socket 或 handle 被突然关掉(比如 socket.destroy()),close 事件将在这个阶段被触发,否则将通过 process.nextTick()触发。
来看一个例子帮助理解:
var fs = require('fs')
function someAsyncOperation(callback) {
// 假设这个任务要消耗 95ms
fs.readFile('/path/to/file', callback)
}
var timeoutScheduled = Date.now()
setTimeout(function() {
var delay = Date.now() - timeoutScheduled
console.log(delay + 'ms have passed since I was scheduled')
}, 100)
// someAsyncOperation要消耗 95 ms 才能完成
someAsyncOperation(function() {
var startCallback = Date.now()
// 消耗 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
})
此时当 event loop 进入了 poll 阶段,发现队列为空,没有代码被 setImmediate。它将会等待剩下的毫秒数,直到最近的 timer 下限时间的到来。当到 95ms 时,fs.readFile 操作结束,其回调被加入到 poll 队列中执行--该回调耗时 10ms。当这一系列执行完成后,时间已经过了 105ms,达到了最近 timer 的下限时间,则回到 timers 阶段,执行 timer 的回调。
这个例子中,poll 阶段即完成了进入 check 阶段完成被 setImmediate 的回调函数,同时也执行了下限时间已经到达的 timers 的回调。
setTimeout 和 setImmediate
setImmediate 方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行。
我们来看一个常见的例子:
setTimeout(() => {
console.log('setTimeout')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
上述的代码执行结果实际上是不确定的。原因在于进入 timer 阶段的时间不确定。根据机器的性能不同,如果进入 timer 节点可能已经过了 1ms,那么 setTimeout 回调会首先执行;而如果小于 1ms,则事件循环会来到 poll 阶段,此时队列为空且有代码被 setImmediate,进入 check 阶段执行 setImmediate 回调函数,等到下个循环的 timer 阶段再执行 setTimeout 的回调函数。
在某些情况下,二者的执行顺序是确定的
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
上述代码的运行结果先执行 setImmediate 回调,再执行 setTimeout 回调。
因为 fs 操作的回调是在 poll 阶段,此时队列为空且有代码被 setImmediate,进入 check 阶段执行 setImmediate 回调函数。
同样地,来看下面的代码:
setTimeout(() => {
setImmediate(() => {
console.log('setImmediate')
})
setTimeout(() => {
console.log('setTimeout')
}, 0)
}, 0)
以上的代码在timers阶段执行外部的setTimeout回调后,内层的setTimeout和setImmediate入队,之后事件循环继续往后面的阶段走,走到 poll 阶段的时候发现队列为空,此时有代码被setImmedate,所以直接进入check阶段执行响应回调(注意这里没有去检测timers队列中是否有成员到达下限事件,因为setImmediate()优先)。之后在第二个事件循环的timers阶段中再去执行相应的回调。
因此,我们可以得出结论:如果两者都不在主模块调用(被一个异步操作包裹),那么setImmediate的回调永远先执行。
proccess.nextTick 与 Promise.then
这两者可以看作是一个微任务,其中proccess.nextTick是在当前"执行栈"的尾部----下一次 Event Loop(主线程读取"任务队列")之前----触发回调函数。
setTimeout(() => {
console.log('timeout0')
process.nextTick(() => {
console.log('nextTick1')
process.nextTick(() => {
console.log('nextTick2')
})
})
process.nextTick(() => {
console.log('nextTick3')
})
console.log('sync')
setTimeout(() => {
console.log('timeout2')
}, 0)
}, 0)
以上代码的执行结果是
// timeout0
// sync
// nextTick1
// nextTick3
// nextTick2
// timeout2
首先是在 timer 阶段执行外层 setTimeout 的回调,执行同步代码,打印出timeout0和sync,遇到 process.nextTick,将其加入微任务中。其次是遇到 setTimeout,将其加入宏任务。此时去检查微任务队列,执行打印出nextTick1,并将其内部的 process.nextTick 推入微任务,接着打印出nextTick2,再执行微任务队列中最后一个nextTick2。微任务执行完成,队列为空。执行 setTimeout 回调,输出timeout2。
与同样作为微任务的Promise.then相比,process.nextTick会更优先执行。
引用知乎黄一君的说法
process.nextTick 永远大于 promise.then,原因其实很简单。。。在 Node 中,_tickCallback 在每一次执行完 TaskQueue 中的一个任务后被调用,而这个_tickCallback 中实质上干了两件事:
- nextTickQueue 中所有任务执行掉(长度最大 1e4,Node 版本 v6.9.1)
- 第一步执行完后执行_runMicrotasks 函数,执行 microtask 中的部分(promise.then 注册的回调)
浏览器与 Node 环境中 Event Loop 的差异
在浏览器环境下,微任务队列是在每执行完一个宏任务之后执行的。而在 Node.js 中,微任务队列会在时间循环的六个阶段之间执行,即一个阶段执行完毕,就会去执行微任务队列的任务。
setTimeout(() => {
setImmediate(() => {
console.log('setImmediate')
Promise.resolve().then(function() {
console.log('promise1')
})
})
setTimeout(() => {
console.log('setTimeout')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
}, 0)
- 首先,外层
setTimeout的回调在timer阶段被执行 - 这时候首先来到了
poll阶段,发现队列为空且有代码被setImmediate,于是进入 check 阶段执行回调函数,打印出 setImmediate,将then的回调推入微任务队列。 - 此时
check阶段结束,于是去检查微任务队列,执行then回调,打印出promise1 - 进入第二轮循环,在
timer阶段执行了内部setTimeout的回调,打印出setTimeout,同样将then方法推入微任务队列 timer阶段结束,检查微任务队列,执行then方法回调,打印出promise2