简单来讲 Event loop 经过将请求分发到别的地方,使得 Node.js 可以实现非阻塞 (non-blocking) I/O 操做html
流程是这样的,你执行 node index.js
或者 npm start
之类的操做启动服务,全部的同步代码会被执行,而后会判断是否有 Active handle,若是没有就会中止。node
好比你的 index.js
是下面这样,那进程运行完便会直接中止git
// index.js
console.log('Hello world');
复制代码
可是,通常来讲咱们都会启动 http 模块,好比下面的 express 的 hello world 事例github
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(port, () => console.log(`Example app listening on port ${port}!`))
复制代码
这里运行了 app.listen
函数就是一个 active handle,有这个的存在,就至关于 Node.js "有理由"继续运行下去,这样咱们就进入了 Event loop。express
Event loop 包含一系列阶段 (phase),每一个阶段都是只执行属于本身的的任务 (task) 和微任务 (micro task),这些阶段依次为:npm
先说简单的 timer 阶段,当你使用 setTimeout()
和 setInterval()
的时候,传入的回调函数就是在这个阶段执行。promise
setTimeout(() => {
console.log('Hello world') // 这一行在 timer 阶段执行
}, 1000)
复制代码
check 阶段和 timer 相似,当你使用 setImmediate()
函数的时候,传入的回调函数就是在 check 阶段执行。浏览器
setImmediate(() => {
console.log('Hello world') // 这一行在 check 阶段执行
})
复制代码
poll 阶段基本上涵盖了剩下的全部的状况,你写的大部分回调,若是不是上面两种(还要除掉 micro task,后面会讲),那基本上就是在 poll 阶段执行的。性能优化
// io 回调
fs.readFile('index.html', "utf8", (err, data) => {
console.log('Hello world') // 在 poll 阶段执行
});
// http 回调
http.request('http://example.com', (res) => {
res.on('data', () => {})
res.on('end', () => {
console.log('Hello world') // 在 poll 阶段执行
})
}).end()
复制代码
这里解答一个我本身的困惑,由于我实际上是卡在这里卡了好久。不知道读者有没有注意到,那就是为何咱们一直在讲回调 (callback)?难道 Node.js 就全是回调么?服务器
嗯,还真的基本上都是。
固然这里的回调是广义的回调,你们能够想想,当咱们运行 server.listen()
以后,剩下的代码是否是都是对各个不一样的请求的处理。只要是请求的处理函数,就都算是回调了,并且更准确的说,这些回调都会进入 poll 阶段。
上面的图就是 Event loop 的各个阶段,注意到,除了咱们上面讲的以外,每一个 phase 还有一个 microtask 的阶段。这个阶段就是咱们下面主要要讲的 process.nextTick
和 Promise
的回调函数运行的地方。
咱们能够想像成每一个阶段有三个 queue,
process.nextTick
的 queuePromise
queue首先采用先进先出的方式处理该阶段的 task,当全部同步的 task 处理完毕后,先清空 process.nextTick
队列,而后是 Promise
的队列。这里须要注意的是,不一样于递归调用 setTimeout
,若是在某一个阶段一直递归调用 process.nextTick
,会致使 main thread 一直停留在该阶段,表现相似于同步代码的 while(true)
,须要避免踩坑。
实践是检验真理的惟一标准,下面代码的运行结果若是和你想的同样,那就说明你掌握了上面的知识,若是不同,那就再看一遍吧。
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
testEventLoop()
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
function testEventLoop() {
console.log('=============')
// Timer
setTimeout(() => {
console.log('Timer phase')
process.nextTick(() => {
console.log('Timer phase - nextTick')
})
Promise.resolve().then(() => {
console.log('Timer phase - promise')
})
});
// Check
setImmediate(() => {
console.log('Check phase')
process.nextTick(() => {
console.log('Check phase - nextTick')
})
Promise.resolve().then(() => {
console.log('Check phase - promise')
})
})
// Poll
console.log('Poll phase');
process.nextTick(() => {
console.log('Poll phase - nextTick')
})
Promise.resolve().then(() => {
console.log('Poll phase - promise')
})
}
复制代码
结果
=============
Poll phase
Poll phase - nextTick
Poll phase - promise
Check phase
Check phase - nextTick
Check phase - promise
Timer phase
Timer phase - nextTick
Timer phase - promise
复制代码
总结下第一部分的内容咱们能够发现,其实 Event loop 就是咱们所认为的 Node.js 的单线程,也就是 main-thread,负责 dispatch tasks 和执行 JavaScript 代码。那当咱们发起 I/O 请求的时候,好比读取文件,是谁来负责执行的呢?这个问题就涉及到咱们这个部分的主要内容 - Node.js 的异步实现方式。
直接说结论,调用操做系统的接口,都是由 Node.js 调用 libuv 的 API 实现的,其中咱们能够将这些异步的 Node.js API 分为两类:
下面的表列出了哪些 API 分别使用哪一种调用机制,固然这些都是由 libuv 封装实现的,Node.js 无需清楚操做系统的类型,或者是异步的方式。
举例来讲,咱们使用的 http 模块就是使用的 kernel async 的方式。这种异步方式由内核直接实现,因此像下面的代码,多个请求之间不会有明显的时间间隔。
const https = require('https')
function testHttps() {
const num = 6;
const startTime = Date.now();
console.log('--------------------')
for(let i=1; i <= num; i++) {
https.request('https://nebri.us/static/me.jpg', (res) => {
res.on('data', () => {})
res.on('end', () => {
const endTIme = Date.now();
const diff = endTIme - startTime;
console.log(`https time ${diff}ms`)
})
}).end()
}
}
testHttps()
/** -------------------- https time 4105ms https time 4332ms https time 4337ms https time 4422ms https time 4454ms https time 4499ms */
复制代码
其中一个使用线程池的例子是 pbkdf2
加密函数。加密是一个很耗费计算 (CPU intensive) 的操做,由 libuv 线程池来模拟异步。线程池默认只有 4 个线程,因此当咱们同时调用 6 个加密操做,后面 2 个会被前面 4 个 block。因此最后的结果会像下面的代码,能够看到第五个明显比前四个要慢。
const crypto = require('crypto')
function testCrypto() {
const num = 6
const startTime = Date.now();
console.log('--------------------')
for(let i=1; i <= num; i++) {
crypto.pbkdf2('secret', 'salt', 10000, 512, 'sha512', () => {
const endTIme = Date.now();
const diff = endTIme - startTime;
console.log(`Crypto time ${diff}ms`)
})
}
}
testCrypto()
/** -------------------- Crypto time 69ms Crypto time 69ms Crypto time 70ms Crypto time 72ms Crypto time 132ms Crypto time 132ms */
复制代码
还有些特殊的状况,好比 fs.readFile
,尽管官方文档说 fs.readFile
也是使用 libuv 线程池的,理论上来讲,应该和 pbkdf2
相似,因为线程池的缘由,第五个文件的读取应该被前四个阻塞,但实际上能够看到结果并非这样。这个我不是很肯定,可是估计是在 Node.js 这里作了 partition 处理,至于什么是 partition?后面会讲。
const fs = require('fs')
function testFile(){
const num = 6
const startTime = Date.now();
console.log('--------')
for(let i=1; i <= num; i++) {
fs.readFile(`index${i}.html`, "utf8", (err, data) => {
const endTIme = Date.now();
const diff = endTIme - startTime;
console.log(`Read file ${i} ` + diff)
});
}
}
testFile()
/** -------- Read file 5 138 Read file 1 159 Read file 2 191 Read file 6 218 Read file 4 243 Read file 3 270 -------- Read file 2 416 Read file 6 444 Read file 4 474 Read file 1 501 Read file 3 531 Read file 8 560 Read file 9 587 Read file 5 656 Read file 7 689 */
复制代码
这部分主要讲咱们在写 Node.js 的时候须要注意什么,其实基本上也就只有一点,和浏览器环境相似,那就是不要阻塞你的主线程 (Do not block you main thread)。至于为何想必你们也都知道,主线程指的是 Event loop,这个被阻塞的话,相似于服务器被 DDOS 攻击,没有办法处理新的请求了。
Node.js API 提供了不少同步的调用方式,一句话,尽可能不要用,由于这些同步调用会阻塞 Event loop。好比 fs.readFileSync()
,尽管是使用 libuv 线程池读取文件的,可是 Event loop 仍是会主动阻塞等待完成。Event loop 这段阻塞的时间完彻底全是浪费的,因此,不要用。
从下面的图片咱们能看出什么?这里先补充一些背景知识:
可是从上面的图片咱们能够发现,在 idle 的时候和在高并发的时候,tick duration 表现很类似。这里就引出了一个 Event loop 的细节,Event loop 在闲置的时候,究竟在干吗。直观理解可能会认为,闲置的时候就一直转圈圈,但从上面的图咱们能够发现,实际上不是的。当 poll 阶段空闲的时候:
为何不少人说 Node.js 不适合作 CPU intensive 的 task。这个其实应区别来讲,首先,由于咱们的主线程其实就是 Event loop。咱们的 JavaScript 代码就运行在 Event loop,若是 JavaScript 代码涉及到太多的计算,的确会致使 Event loop 阻塞。可是实际上 CPU intensive 的部分咱们能够交给别人来作,这个操做就叫作 offloading。好比 pbkd2
加密,是交给 libuv 的线程池来搞定的,并不会阻塞主线程,也就不会有什么问题。
上面的 offloading 至关于把任务交给别人作,咱们只要作任务完成后的回调就能够。还有一种不阻塞主线程的方式叫 partition (能够看成时间切片) 。好比咱们要计算一个累加,若是遇到大数的状况,有可能会阻塞主线程。可是能够用 partition 的方式异步处理,这样就将时间复杂度从原来的 O(n) 变成 n * O(1),不会阻塞 Event loop。
function normalAdd(n) {
const start = Date.now();
let sum = 0
for (let i=1; i <=n; i++) {
sum += i
}
const end = Date.now();
const diff = end - start;
console.log('normal time ' + diff)
return sum
}
function partitionAdd(n, cb) {
const start = Date.now();
let sum = 0
let i = 1
const count = () => {
if (i <= n) {
sum += i
i += 1
return setImmediate(count)
}
const end = Date.now();
const diff = end - start;
console.log('partition time ' + diff)
cb(sum)
}
setImmediate(count)
}
console.log(normalAdd(1000000));
partitionAdd(1000000, console.log);
/** normal time 4 500000500000 partition time 943 500000500000 */
复制代码
由上面的图能够看出 Event loop duration 没办法反应出服务当前的健康状况,由于空闲状况和高并发状况的表现相似,那咱们有什么方式监控能咱们的 Node.js 服务是否正常处理用户请求呢?**Event loop latency ** 是一个很好的指标。
咱们知道 setTimeout
的回调函数过时后会在 timer 阶段执行,可是若是若是 poll 阶段的任务执行时间过长,setTimeout
的回调函数过时后也不必定当即执行,而是会有一段时间的 delay,若是这个 delay 的时间过长,就说明 Event loop 在 poll 阶段被阻塞了。
console.log('start', Date.now())
setTimeout(() => {
console.log('end', Date.now())
}, 1000)
// end - start 有可能会 > 1000ms
复制代码