Nodejs中的事件循环、定时器、nextTick()以及与EventEmitter的关系详解

参考资料:The Node.js Event Loop, Timers, and process.nextTick()

什么是事件循环?

事件循环允许nodejs完成非阻塞的IO操作(尽管JavaScript是单线程的)。

简单来说,在nodejs中,遇到IO操作或者网络连接等阻塞性的行为的时候,将这个操作交给nodejs底层的线程池去处理,而不会阻塞主线程。当线程池中的任务完成之后,会将结果和回调函数推入到事件队列中,主线程完成当前正在执行的脚本之后(IO中指定的回调函数),会去检查事件队列并执行其中的回调,直到事件队列为空。

这只是一个大概的概述,接下来解释事件队列的细节

事件循环的模型

当nodejs启动的时候会初始化事件循环,在执行的第一个脚本中可能会执行异步的操作,然后就会处理事件循环。

下面是Node事件循环的模型图,事件循环中的操作按照途中所给的顺序执行。图中的每一个盒子都可以看成事件循环中的一个阶段。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

在事件循环的每一个阶段,都有一个先入先出(FIFO)队列,用来存放要执行的回调函数。当事件循环进入到某一个阶段的时候,按照队列的顺序执行队列中的所有回调函数,直到当前队列中的所有回调函数都被执行或者达到了回调函数的执行限制(如果在poll阶段一直有任务执行,那么timer阶段的任务就可能用于不会执行了,nodejs中为了避免这种其他阶段过于“饥饿”的情况,底层的libev有一个限制,达到限制之后不会向事件循环中添加更多的任务)。

之后会切换到下一个阶段,执行该阶段的任务队列。

实例:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

假定fs.readFile需要95毫秒完成,执行回调函数需要10ms,setTimeout设定为100ms后执行。

那么程序的执行顺序如下:

  • [0-95]ms 事件队列为空,nodejs保持等待
  • [95-105]ms 执行readFile的回调
  • 105ms 执行setTimeout的回调
    在100ms时,setTimeout到达指定的事件阈值,被放入timer阶段的队列中,等待pool阶段的任务(这里是readFile)结束了之后再被调用。

阶段细节

  • timers
    当使用setTimeout或者setInterval指定延迟的时间到达之后,会将任务添加到timers队列中,等待其他阶段的任务队列中的执行完成之后执行。所以实际脚本执行的时刻>=设定的时间,因为此时可能还有其他的任务在队列中等待执行
  • pending callback
    这个阶段执行一些系统操作的回调函数比如TCP错误的类型。
  • pool
    接受新的I/O事件;执行I/O相关的回调
  • check
    setImmediate()回调这个阶段触发
  • close callbacks
    一些关闭回调,比如socket.on(‘close’,…)。如果一个socket或者句柄(handle)被突然得关闭(比如socket.destroy()),close事件会在这个阶段被抛出。否则会通过process.nextTick()抛出。

process.nextTick()

相信你已经发现process.nextTick()并没有在图中,这是因为process.nextTick()并不是任务循环中的一部分。

process.nextTick()指定的回调函数将会被加入到nextTickQueue队列中。

nextTickQueue中的回调将会在下一次任务循环之前执行。具体来说,就是在一段代码执行时候,会先检查nextTickQueue,如果nextTickQueue中有回调,会先执行nextTickQueue中的回调,而不管现在是在事件循环的哪一个阶段。

通过递归使用process.nextTick可以阻止事件循环

EventEmitter和事件循环的关系

EventEmitter实现了发布订阅模式,但是EventEmitter回调函数的执行本身不是异步的,当event.emit(‘event’)执行的时候,所有订阅了event事件的回调函数会立即执行(按序)。但是我们监听tcp连接的时候,连接的回调函数时按顺序执行的,前面的连接会阻塞后面的响应,这是因为使用了process.nextTick或者setImmediate
比如:

http.on('request', function(req,res){
	...
})

如果request被while循环阻塞,那么后面的http请求都会在pending状态,因为此时他们正在队列中。

下面是另一个例子:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  process.nextTick(() => {
	this.emit('event');
  })
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

上面将this.emit(‘event’)放入nextTickQueue队列,所以注册event事件的代码会先执行,这样event事件被emit的时候,事件是被注册了的。

总结

我们看到了nodejs事件循环的细节,以及process.nextTick与事件循环的关系。


版权声明:本文为weixin_37994110原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。