Node.js Event loop 原理

Event Loop

为何会有 Event loop

简单来讲 Event loop 经过将请求分发到别的地方,使得 Node.js 可以实现非阻塞 (non-blocking) I/O 操做html

Event loop 是如何工做的

流程是这样的,你执行 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

  1. timers
  2. pending callbacks
  3. idle, prepare
  4. poll
  5. check
  6. close callbacks
  • 先说简单的 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.nextTickPromise 的回调函数运行的地方。

Microtask

咱们能够想像成每一个阶段有三个 queue,

  1. 这个阶段的"同步" task queue
  2. 这个阶段的 process.nextTick 的 queue
  3. 这个阶段的 Promise 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
复制代码

libuv 线程池与内核

总结下第一部分的内容咱们能够发现,其实 Event loop 就是咱们所认为的 Node.js 的单线程,也就是 main-thread,负责 dispatch tasks 和执行 JavaScript 代码。那当咱们发起 I/O 请求的时候,好比读取文件,是谁来负责执行的呢?这个问题就涉及到咱们这个部分的主要内容 - Node.js 的异步实现方式。

直接说结论,调用操做系统的接口,都是由 Node.js 调用 libuv 的 API 实现的,其中咱们能够将这些异步的 Node.js API 分为两类:

  1. 直接用内核 (Kernel) 的异步方法
  2. 使用线程池 (Thread pool) 来模拟异步

下面的表列出了哪些 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 攻击,没有办法处理新的请求了。

不要使用 *sync

Node.js API 提供了不少同步的调用方式,一句话,尽可能不要用,由于这些同步调用会阻塞 Event loop。好比 fs.readFileSync(),尽管是使用 libuv 线程池读取文件的,可是 Event loop 仍是会主动阻塞等待完成。Event loop 这段阻塞的时间完彻底全是浪费的,因此,不要用。

When event loop idle

从下面的图片咱们能看出什么?这里先补充一些背景知识:

  • 什么是 tick?一个 tick 指的是 Event loop 完整的走完一圈
  • tick frequency 指的是 tick 的频率,tick duration 指的是一个 tick 的时间长度。通常咱们认为,tick duration 越短越好,意味着能更快相应新的请求。

可是从上面的图片咱们能够发现,在 idle 的时候和在高并发的时候,tick duration 表现很类似。这里就引出了一个 Event loop 的细节,Event loop 在闲置的时候,究竟在干吗。直观理解可能会认为,闲置的时候就一直转圈圈,但从上面的图咱们能够发现,实际上不是的。当 poll 阶段空闲的时候:

  • 若是没有 timer (这里包括 setTimeout, setInterval )和 setImmediate ,就会一直在 poll 阶段阻塞;
  • 若是有已经到时的 timer 或者 setImmedate,则会 proceeds to next phase

Offloading

为何不少人说 Node.js 不适合作 CPU intensive 的 task。这个其实应区别来讲,首先,由于咱们的主线程其实就是 Event loop。咱们的 JavaScript 代码就运行在 Event loop,若是 JavaScript 代码涉及到太多的计算,的确会致使 Event loop 阻塞。可是实际上 CPU intensive 的部分咱们能够交给别人来作,这个操做就叫作 offloading。好比 pbkd2 加密,是交给 libuv 的线程池来搞定的,并不会阻塞主线程,也就不会有什么问题。

Partition

上面的 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 */

复制代码

如何监控 Node.js 服务

由上面的图能够看出 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
复制代码

Ref

相关文章
相关标签/搜索