大家好,我是二哥。
闷热的夏天终于过去了,二哥在初秋回来了。有不少人在后台问二哥为啥这么久不更新文章了。能有啥原因,热得呗。
开玩笑,其实在这个夏天,二哥的业余时间主要花在了两个方面:每天晚上陪小朋友去小区后面的公园打球、看书充电。原本打算哪天下雨的时候就在家写文章,奇了怪了,这个暑假,竟然没有哪天晚上下雨。
言归正传。这次我们来聊聊 Node.js 里面涉及到的一个核心概念:event-loop 。只有理解了它,才能明白 node 的进程模型,也才能明白异步调用在实现层面是什么样子的,更能明白当同步代码和异步代码混杂在一起的时候,CPU 到底跑到我们代码的哪一行了。
文章分为两篇:event-loop 篇和 Promise/Generator/async 篇。今天我们关注 event-loop 部分。
1. 代码思考
我写了两个函数,函数内部直接用 while(true){} 写了一段死循环代码。我们先来思考下面这段 Node.js code 执行结果是什么?
很多人说 Node.js 是单线程的。如果是这样,那 CPU 会不会陷入到 whileLoop_1() 的 while 循环里面出不来?
代码语言:javascript复制'use strict';
async function sleep(intervalInMS)
{
return new Promise((resolve,reject)=>{
setTimeout(resolve,intervalInMS);
});
}
async function whileLoop_1(){
while(true){
try {
console.log('new round of whileLoop_1');
await sleep(1000); // LINE-A
} catch (error) {
// ...
}
}
}
async function whileLoop_2(){
while(true){
try {
console.log('new round of whileLoop_2');
await sleep(1000); // LINE-B
} catch (error) {
// ...
}
}
}
whileLoop_1(); // LINE-C
whileLoop_2(); // LINE-D
不卖关子了,我先把执行结果发出来。
代码语言:javascript复制new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
new round of whileLoop_1
new round of whileLoop_2
...
是的,正如你所见。这两个 while 循环分别在交替执行, CPU 也没有陷入到死循环里面出不来。那么问题来了:
- CPU 执行到 LINE-A 的时候发生了什么使得它能成功脱身并有机会执行 whileLoop_2 ?
- CPU 执行到 LINE-B 后,为什么又能回到 whileLoop_1 中继续执行呢?
2. event-loop
在回答上面的问题前,我们需要先来看一个至关重要的概念:event-loop 。
其实我们平时说 Node.js 是单线程仅仅是指 node 执行我们的 JS 代码,更准确地说是 V8 执行 JS code 是发生在单线程里面的。实际上如果你打开 node 进程,会发现它有不少 worker thread。这是一个典型的单进程多线程模型。这些 worker thread 被放置于线程池里面,而 V8 执行 JS code 的线程被称为主线程。
主线程和线程池的配合关系如下图所示。主线程负责执行 JS code ,线程池里面的 worker thread 负责执行类似访问 DB、访问文件这样的耗时费力的工作,它俩通过消息队列协调工作。
这和餐馆工作流程类似。餐馆由一个长得漂亮的小姐姐招呼客人落座并负责收集来自各个餐桌的点单。每当收到一个点好的菜单时,小姐姐会迅速地把它通过一个小窗口递交给后厨。后厨那里有一个小看板,所有的点单都被陈列在看板上。厨师长根据订单的时间和菜品安排不同的厨师烧菜。菜烧好后,再由小姐姐负责上菜。
图 1:Node.js 单进程多线程模型
嗯上面这张图还是太简单了,用来骗新手可以,我知道满足不了你们。我们把它放大一些。下图中左边是主线程,右边是线程池和一个 worker thread,中间是消息队列。
图 2:Node.js 主线程和工作线程关系图
2.1 主线程
主线程只干一件事:拼命地执行 JS code,做 non-blocking I/O 操作。这些 JS code 既包含我们自己写的,也包含我们所依赖的 npm package 。这里提到的 non-blocking I/O 操作意味着主线程干的事情基本上都是非阻塞型的工作,例如对 2 3 求和,迭代数组等。
主线程以 tick 为粒度工作。是的,你一定听说过 process.nextTick() ,所谓 next tick 就是下一次执行 tick 的时机。每个 tick 又包含若干个 phase ,按照 Node.js 官网介绍,目前为止一共有 6 个 Phase。
- timers: 这个 phase 执行通过
setTimeout()
和setInterval()
所设置的 callback 函数。 - pending callbacks: 这个 phase 执行一些与系统操作相关的 callback,比如建立 TCP 连接时收到的 ECONNREFUSED 相关的 callback 。
- idle, prepare: 仅供Node.js内部使用。
- poll: 从消息队列里面获取新的 I/O event,执行相应的 callback (不包括 setImmediate / close callback / 以及 timer 所设置的 callback)。
- check: 执行通过
setImmediate()
所设置的callback. - close callbacks: 执行一些 close callback ,比如通过这样的代码
socket.on('close', ...)
所设置的 callback。
绝大部分情况下,这些 callback 是用 JS 写的,Node 通过 Google V8 engine ,在主线程里面来执行这些 callback 。
我们把上面的 6个 phase 和 tick 的关系放置到时间轴上,或许能更形象地说明主线程所做的工作。
图 3:Node.js 主线程时序图
2.2 消息队列
主线程不单单是在执行 JS code,也不仅仅只是在做 non-blocking I/O 操作。它在执行代码的过程中,还会产生各种各样的异步请求。直观一点的如通过 setImmediate(callback[, ...args]) / fs.readFile(path[, options], callback) 产生,晦涩一点的如通过 Promise / async 产生。
这些异步请求大部分情况下有一些共性:需要耗费一定的时间去处理。让主线程放着其它事情不管,傻傻地干等这次操作的结果可不是聪明的做法。所以它们都会被封装成 async Request,并被交给线程池去处理。
还记得我们之前举的餐馆工作流程的例子吗?烧菜是一个费时间的事情,如果小姐姐拿到我们的订单,自己跑到后厨去烧菜会出现什么后果?等她把单子上的菜都烧好再去下一桌点菜的话,对客人而言就出现了一个 blocking I/O 操作:进餐馆没有人接待了。
消息队列就如同后厨那里的看板。小姐姐只负责往看板上添加新的订单,而订单的制作交由厨师团队来完成。
2.3 工作线程
工作线程来完成具体的 I/O 请求操作。通常这个过程藉由 OS 所提供的异步机制来完成。如 Windows 里面的 IO 完成端口(IOCP)、Linux 里面的异步 IO。
如图 2 所示,当工作线程完成了一个异步请求后,会把操作结果放置到一个消息队列里面。从图中可以看到,主线程运行所涉及到的每个 phase 都有各自专属的消息队列。
消息队列里面有了消息,意味着主线程又需要干活了,干活的过程中会继续产生新的异步请求,工作线程继续不知疲倦地搬砖。完美的闭环。
有一种场景图 2 并没有画出来,当 Node.js 收到来自系统外部的事件如网络请求时,工作流程是什么样子的?
到目前为止我们谈及的 event 都是由 JS code 主动触发的,如果我们说这种 event 是由顶向下触发的话,网络请求这样的 event 是由底向上触发的。聪明的你一定可以在脑袋里大致画出一条线出来:这条线的起点是位于内核的网卡驱动,终点是 Node.js 主线程,中间依次经过了内核协议栈,Node.js 的消息队列。
3. 小结
行文至此,可以看到 Node.js 是一个完完全全的消息驱动型模型。Node进程活着的最大意义是:有各种各样的 event 以及绑定在 event 上面的 callback 和 data需要它(main thread 和 worker thread)处理。event 的 callback 中也可能会产生新的异步请求,进而产生新的 event 。正是这些源源不断的 event 驱动着 Node 活下去。如果没有event需要Node进程处理了,它也就没有存在的必要了。
Node.js 还是一个标准的单进程多线程模型。其中主线程用来执行我们所写的 JS code ,而线程池里面的 worker thread 则用来执行各种耗时长的 I/O 操作。这些操作可能会导致 worker thread 被阻塞掉。worker thread 被阻塞没有关系,但主线程被阻塞就不太美丽了。
最后再强调一下:我们所写的 JS code 是交由 V8 在单线程里面运行的,所以尽量不要在 JS code 里面执行耗时长的同步操作。
下一篇我们细聊 Promsie / Generator / Async 之间的关系,尝试剖析 async 作为 Generator 和自动运行器的语法糖的细节,并解释这样的机制是如何影响本篇开始的代码行为的。
以上就是本文的全部内容。码字不易,画图更难。喜欢本文的话请帮忙转发或点击“在看”。您的举手之劳是对二哥莫大的鼓励。谢谢!