Node.js Event loop 原理

3,001 阅读5分钟

Event Loop

为什么会有 Event loop

简单来说 Event loop 通过将请求分发到别的地方,使得 Node.js 能够实现非阻塞 (non-blocking) I/O 操作

Event loop 是如何工作的

流程是这样的,你执行 node index.js 或者 npm start 之类的操作启动服务,所有的同步代码会被执行,然后会判断是否有 Active handle,如果没有就会停止。

比如你的 index.js 是下面这样,那进程运行完便会直接停止

// index.js
console.log('Hello world');

但是,一般来说我们都会启动 http 模块,比如下面的 express 的 hello world 事例

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。

Event loop 包含一系列阶段 (phase),每个阶段都是只执行属于自己的的任务 (task) 和微任务 (micro task),这些阶段依次为:

  1. timers
  2. pending callbacks
  3. idle, prepare
  4. poll
  5. check
  6. close callbacks
  • 先说简单的 timer 阶段,当你使用 setTimeout()setInterval() 的时候,传入的回调函数就是在这个阶段执行。

    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