Node中的事件循环

5次阅读

共计 3737 个字符,预计需要花费 10 分钟才能阅读完成。

Node 中的事件循环

如果对前端浏览器的时间循环不太清楚,请看这篇文章。那么 node 中的事件循环是什么样子呢?其实官方文档有很清楚的解释,本文先从 node 执行一个单文件说起,再讲事件循环。

node 的内部模块

任何高级语言的存在都有一定的执行环境,比如浏览器的代码是在浏览器引擎中,那么在 node 环境中也有一定的执行环境。我们先来看一下官网的依赖包有哪些?

  • V8
  • libuv
  • http-parser
  • c-cares
  • OpenSSL
  • zlib

上面就是 nodejs 中依赖的模块。那么这些模块之间是如何工作的呢?模块之间的工作关系如下图所示:


主要过程如下:

  • step1: 用户的代码通过 v8 引擎解释器,解析为两部分:” 立即执行 ” 和 ” 异步执行 ”。

立即执行:可以理解为,需要 v8 引擎去处理的代码;
异步执行:并不是真正的异步,可以理解为,不需要 v8 引擎处理的和需要异步处理的。

  • step2:“异步执行”的部分,通过 v8 引擎和底层之间建立的绑定关系,去执行对应的操作
  • step3: 在“异步执行”部分,通过 libuv 内部的事件循环机制,无阻塞调用。libuv 在执行的时候,主要通过 handles 和 request 实现对应的操作,handles 和 requests 具备不同的数据结构。官网解释,handles 是长期存在的对象,request 是短期存在的对象,猜测来讲,requests 和 handles 有不同的垃圾回收机制。

libuv 的事件循环

一个线程有唯一的一个事件循环(event loop)。线程非安全。

这里需要理解两点:

  • 线程

这可能和我们理解的不太一样,Javascript 代码是单线程的,但是 libuv 不是单线程的,他可以开启多个线程,libuv 提供了一个调度的线程池,线程池中的线程数目,默认是 4 个,最多 1024 个(为什么?因为每一个线程都会占用资源,而内存是有限的),关于线程池的可以看官方文档。

  • 线程安全

对数据的操作无非就是读和写,线程安全,简单来说,就是一个线程对这一份数据具有独占性,只有当该线程操作完成,其他线程才可以进行操作,当然线程安全的概念远不止这些,详细可以看维基百科,这里就简单理解一下就行了。

libuv 中的事件循环

事件循环图,如下所示:

主要分为下面几步:

  • step1: 线程启动时,初始化一个时间:now,为了计算后面的 timer 的回调函数什么时候执行
  • step2: 判断事件循环是否存活,如果不存活,立即退出,否则进行下一步。判断是否存活的依据:索引是否存在。索引就是指否还有需要执行的事件,是否还有请求,关闭事件循环的请求等等。(用白话来讲,就是看还有没有没处理的事情)
  • step3: 执行所有的定时器(timers)在事件循环之前
  • step4: 执行待执行(pending)的回调,一般的 IO 轮询都会在轮询后,立即执行,但是有的也会延迟(defer)执行,延迟执行的,就会在这个阶段执行
  • step4: 执行空闲(idle)函数,每个阶段都会执行的,一般情况下是执行一些必要的操作,程序内置的
  • step5: 执行准备好的回调函数,具体内部使用的
  • step6: IO 轮询执行,直到超时,在阻塞执行之前,会计算超时时间,也就是停止轮询的时间:

    • 如果队列为空、或者是即将关闭,或者有将要关闭的 handles,timeout 为 0
    • 如果没有上面的情况,超时时间就取最近的 timer 时间,否则就是无穷大

(用白话来理解,就是看有没有要关闭的,有的话,就直接往下走,没有的话,看看有哪个事件比较急,到了点就去执行)

  • step7: 执行 IO
  • step8: 检查接下来要执行哪些 handle,保证正确执行
  • step9: 是否存在关闭的回调,如果有就执行,关闭循环,否则继续循环

通常情况下来讲,文件的 I / O 会调用线程池,但是网络请求的 I / O 总是用同一个线程。

Node 中的事件循环

阻塞和非阻塞

node 中所有的代码几乎都提供了同步 (阻塞) 和异步 (非阻塞) 的方式,你可以选择使用哪一种方式,但是不要混合使用。

node 中的事件循环,就是一个简版的 libuv 事件循环机制图

NodeJs 中的定时器

NodeJs 中的定时器主要有三种:

  • setTimeout
  • setInterval
  • setImmediate

三个定时器都有对应的取消函数:

  • clearTimeout
  • clearInterval
  • clearImmediate

setTimeout && setInterval

setTimeout 和 setInterval 行为和在浏览器环境中的行为类似,但是 setTimeout 和 setImmediate 有一点不同。在 libuv 中可以看到,判断循环是否结束的时候,是需要判断是否还有待执行的函数,如果只剩下一个 setTimeout 或者 setInterval 函数,那么整个循环还会继续存在,node 提供了一个函数,可以让循环暂时 休眠

  • unref
  • ref

unref 是可以让 setTimeout 暂时休眠,ref 可以再次唤醒

setImmediate

setImmediate 是指定在事件循环结束执行的。主要发生在 poll 阶段之后

如果 poll 队列没空,则一直执行,直到对列空位置

如果 poll 队列空了,有 setImmediate 事件,则会跳到 check 阶段

如果 poll 队列空了,没有 setImmediate 事件,就会查看哪一个 timer 事件快要到期了,转到 timers 阶段

依据上面的解释,就有了 setTimeout 和 setImmediate 执行先后顺序的问题:

setTimeout(() => {console.log('timeout');
})
setImmediate(() => {console.log('immediate);
});

先说答案:

可能会有两种情况:
timeout
immediate
或者
immediate
timeout

为什么?
主要是 setTimeout 在前或者后的问题,依赖于线程的执行速度。
主要是两个阶段:

  • 1、v8 引擎执行环境扫描代码,启动事件循环,当走到 setTimeout 的时候,会将 timeout 丢进 libuv 事件队列中
  • 2、v8 引擎继续执行,走到 setImmediate

    • 此时,上面的 libuv 事件队列可能执行第一次,刚走到 poll 阶段,那么接下来就会打印 immediate,
    • 也可能 libuv 事件队列,已经第二次循环,经过了 poll 阶段,然后判断 timeout 到时间了,去执行 timeout 了,这样就会先打印 timeout 然后再打印 immediate

所以根本原因是在于 事件循环执行了一次还是两次。

那我们接下来看看事件循环的逻辑

nextTick

Node 添加了这样一个 API,这个并不在事件循环的机制内,但是和时间循环机制相关。先来看一下定义:

nextTick 的定义是在事件循环的下一个阶段之前执行对应的回调。

虽然 nextTick 是这样定义的,但是它并不是为了在事件循环的每个阶段去执行的。
主要有下面两种应用场景:

  • 作为下一个执行阶段的钩子,去清理不需要的资源,或者再次请求
  • 等运行环境准备好之后,再去执行回调

案例一:

let bar;

function someAsyncApiCall(callback) {callback()
  process.nextTick(callback);
}

someAsyncApiCall(() => {console.log('bar', bar); // 1
});

bar = 1;

// 输出
undefined
1

输出 undefine 的情况是,因为执行函数的时候,bar 并没有被赋值,而 process.nextTick 则能保证整个执行环境都准备好了再去执行

案例二:

const server = net.createServer();
server.on('connection', (conn) => {});

server.listen(8080);
server.on('listening', () => {});

当 v8 引擎执行完代码后,listen 的回调会直接命中 poll 阶段,那么 server 的 connect 事件就不会执行

案例三:

想要在构造函数中,去发送对应的事件,因为此时 v8 引擎还没有扫描到,而构造函数的代码会立即执行,就需要 nextTick

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

function MyEmitter() {EventEmitter.call(this);
  // 这样操作无效
  this.emit('event');
  // 应该这样
  // process.nextTick(() => {this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);

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

总结

上面三个案例,重点在于 v8 引擎是单线程立即执行,而 libuv 则是异步执行,想要在异步循环之前执行一些操作就需要 process.nextTick

参考文档

Node 官网解释
libuv 的设计
关于 libuv 的概念详细解释
libuv 线程池实现
并发

正文完
 0