相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?
讲到线程,那么肯定也得说一下进程。本质上来说,两个名词都是 CPU 工作时间片的一个描述。
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。
把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数字 15 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,直到读取完毕之前都不能进行写入操作。
由于js是一个单线程,为了处理异步的情况引入了事件队列机制。当js引擎遇到异步事件时不会一直等待其返回结果,而是将这个事件挂起,继续执行执行栈的任务,当异步事件返回结果后,也不会立即执行其回调,而是将其加入到一个事件队列中,等当前执行栈中的所有任务都执行完毕,主线程处于闲置状态时,主线程会去查找事件队列是否有任务,如果有则从队列中取出事件回调去执行。而队列中的任务又分为“微任务”和“宏任务”。
具体规则为当当前执行栈执行完毕时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
所以 Event Loop 执行顺序如下所示:
这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。
Node 中的 Event Loop 和浏览器中的是完全不相同的东西。在 node 中,事件循环表现出的状态与浏览器中大致相同。不同的是 node 中有一套自己的模型。node 中事件循环的实现是依靠的libuv引擎。我们知道 node 选择chrome v8引擎作为js解释器,v8引擎将 js 代码分析后去调用对应的node api,而这些 api 最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上 node 中的事件循环存在于libuv引擎中。
下面是一个libuv引擎中的事件循环的模型:

从上面这个模型中,我们可以大致分析出node中的事件循环的顺序:
外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段…
setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。close事件,定时器和setImmediate()的回调(上一轮循环中的少数未执行的 I/O 回调)。libuv引擎后,循环首先进入poll阶段setImmediate和timer回调
setImmediate的回调,如果有会停止poll阶段并进入check阶段执行这些回调I/O事件回调
setImmediate和timer的都没有回调,那么loop会在poll阶段停留,直到有一个I/O事件返回,循环会进入I/O callback阶段并立即执行这个事件的回调setImmediate()的回调会在这个阶段执行。在node中有三个常用的用来推迟任务执行的方法:process.nextTick,setTimeout(setInterval与之相同)与setImmediate
尽管没有提及,但是实际上node中存在着一个特殊的队列,即nextTick queue。这个队列中的回调执行虽然没有被表示为一个阶段,当时这些事件却会在每一个阶段执行完毕准备进入下一个阶段时优先执行。当事件循环准备进入下一个阶段之前,会先检查nextTick queue中是否有任务,如果有,那么会先清空这个队列。与执行poll queue中的任务不同的是,这个操作在队列清空前是不会停止的。这也就意味着,错误的使用process.nextTick()方法会导致node进入一个死循环。。直到内存泄漏。
process.nextTick可以认为是一个类似于Promise和MutationObserver的微任务实现,在代码执行的过程中可以随时插入nextTick,并且会保证在下一个宏任务开始之前所执行。
process.nextTick其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他微任务执行。
在使用方面的一个最常见的例子就是一些事件绑定类的操作:
class Lib extends require('events').EventEmitter {
constructor () {
super()
this.emit('init')
}
}
const lib = new Lib()
lib.on('init', _ => {
// 这里将永远不会执行
console.log('init!')
})
因为上述的代码在实例化Lib对象时是同步执行的,在实例化完成以后就立马发送了init事件。
而这时在外层的主程序还没有开始执行到lib.on(‘init’)监听事件的这一步。
所以会导致发送事件时没有回调,回调注册后事件不会再次发送。
我们可以很轻松的使用process.nextTick来解决这个问题:
class Lib extends require('events').EventEmitter {
constructor () {
super()
process.nextTick(_ => {
this.emit('init')
})
// 同理使用其他的微任务
// 比如Promise.resolve().then(_ => this.emit('init'))
// 也可以实现相同的效果
}
}
这样会在主进程的代码执行完毕后,程序空闲时触发Event Loop流程查找有没有微任务,然后再发送init事件。
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
对于以上代码来说setTimeout和setImmediate执行顺序是随机的,setTimeout 可能执行在前,也可能执行在后:
如下代码setTimeout会先执行,但正常情况下我们不会这么写:
setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))
let countdown = 1e9
// 我们确保这个循环的执行速度会超过定时器的倒计时,导致这轮循环没有结束时,setTimeout已经可以执行回调了
// 所以会先执行`setTimeout`再结束这一轮循环,也就是说开始执行`setImmediate`
while(countdown--) { }
还有一种情况下可以准确判断两个方法回调的执行顺序,那就是在一个I/O事件的回调中。下面这段代码的顺序永远是固定的:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
答案永远是:
immediate
timeout
因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。 记住:在I/O事件的回调中,setImmediate方法的回调永远在timer的回调前执行。

node中新增微任务的process.nextTick以及宏任务的setImmediate。
setTimeout(_ => console.log(4))
new Promise(resolve => {
resolve()
console.log(1)
}).then(_ => {
console.log(3)
Promise.resolve().then(_ => {
console.log('before timeout')
}).then(_ => {
Promise.resolve().then(_ => {
console.log('also before timeout')
})
})
})
console.log(2)
// 输出
// 1
// 2
// 3
// before timeout
// also before timeout
// 4