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线程池实现
并发