Nodejs中的事件循环
事件循环的概念
提及事件循环(Event Loop), 想必大家第一反映就是浏览器中的事件循环、宏任务、微任务、任务队列等概念。这些设计就是用来解决js单线程带来的异步问题。不清楚的小伙伴, 可以翻阅现代JavaScript教程中的相关介绍, 个人感觉讲的还是很不错的。
但是今天想和大家分享的Nodejs事件循环, 和浏览器的事件循环完全不一样。
事件循环的6个阶段
上图就是一轮事件循环所要经历的六个阶段。 需要注意的是,这幅图只是各阶段的排序图, 但是实际事件循环在执行回调时,会因回调的触发时机不同,导致执行顺序不同。
timers (定时器)
此阶段执行那些由 setTimeout() 和 setInterval() 调度的回调函数.- I/O callbacks (I/O回调)
此阶段会执行几乎所有的回调函数, 除了 close callbacks(关闭回调) 和 那些由 timers 与 setImmediate()调度的回调. - idle(空转), prepare
仅系统内部使用。 poll(轮询)
检索新的I/O事件; 在恰当的时候Node会阻塞在这个阶段check(检查)
setImmediate() 设置的回调会在此阶段被调用- close callbacks(关闭事件的回调)
诸如 socket.on(‘close’, …) 此类的回调在此阶段被调用
在事件循环的每次运行之间, Node.js会检查它是否在等待异步I/O或定时器, 如果没有的话就会自动关闭.
是不是看了有点上头?没关系, 我来画一张图:
我们以poll阶段为当前参考对象,把事件循环想象成排队打疫苗,那么现在这个队伍就有3个主要参与者(其他暂时忽略):1. timers, 2.poll, 3. check。
假设排在第一个的timers迟到了,还没有来(代码未设定timers,即定时器内的回调事件还没到, 或没有设置定时器)
- 在轮到poll打疫苗的时候, 医生会先看看poll到底来了没有(是否为空闲状态),如果poll到了,那就给poll打。
- 但是如果poll也迟到了, 此时这个位置就空着了(poll queue为空)那么,医生会先回头看看timers有没有来(timers里有没有放入定时器内的回调事件),如果timers此时来了, 那会让timers先打疫苗, 毕竟人家是先挂号的(事件循环绕回到timers阶段)。
- 如果poll、timers都还没来(poll queue为空,且代码未设定timer)那么医生就要看看后面的check来了没有,如果check来了(代码已经被setImmediate()设定了callback),那就立刻给check打疫苗(立即进入check阶段, 执行setImmediate的回调)
- 但是如果check也没有来,那么意味着没有人来排队打疫苗, 那么医生会在poll阶段一直等待,这就是所谓的 “在恰当的时候Node会阻塞在这个阶段”。
demo
const fs = require('fs');
const path = require('path');
const timeoutScheduled = Date.now();
function someAsyncOperation (callback) {
// 因为fs读取文件的速度很快(大概1.几毫秒), 所以我们这里假设当前读取文件需要2毫秒
fs.readFile(path.resolve(__dirname, './read.txt'), callback);
}
setTimeout(function () {
const delay = Date.now() - timeoutScheduled;
console.log('setTimeout');
}, 10);
someAsyncOperation((err, data) => {
fileReadtime = Date.now();
// 通过while强行阻塞20ms, 那么从读取到执行回调 总共消耗2-22ms
while(Date.now() - fileReadtime < 20) {
console.log('readFile');
}
});
setImmediate(() => {
console.log('check阶段')
})
当然, 如果setTimeout时间阈值改为2ms, 读取文件的事件改为10ms, 那么结果就是:
setTimeout和setImmediate
一个很有意思的demo
setTimeout(function timeout () {
console.log('timeout');
}, 0);
setImmediate(function immediate () {
console.log('immediate');
});
定时器可能是不准的
在nodejs中, 你会发现 他们的结果是不确定的, 会有概率出现setImmediate先执行的情况, 这是因为:
node.js里面setTimeout(fn, 0)会被强制改为setTimeout(fn, 1),这在官方文档中有说明。如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout执行,如果1毫秒还没到,就先执行了setImmediate。在浏览器中一样存在这个问题, 即setTimeout最小时间阈值为4ms
特殊的process.nextTick
process.nextTick()不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行。
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
process.nextTick(()=>{
console.log('nextTick3');
})
});
process.nextTick(()=>{
console.log('nextTick1');
})
process.nextTick(()=>{
console.log('nextTick2');
})
});
nextTick应用场景
- 在多个事件里交叉执行CPU运算密集型的任务:
const http = require('http');
function compute() {
process.nextTick(compute);
}
http.createServer(function(req, res) { // 服务http请求的时候,还能抽空进行一些计算任务
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World');
}).listen(5000, '127.0.0.1');
compute();
在这种模式下,我们不需要递归的调用compute(),我们只需要在事件循环中使用process.nextTick()定义compute()在下一个时间点执行即可。在这个过程中,如果有新的http请求进来,事件循环机制会先处理新的请求,然后再调用compute()。反之,如果你把compute()放在一个递归调用里,那系统就会一直阻塞在compute()里,无法处理新的http请求了。
- 保持回调函数异步执行的原则
当你给一个函数定义一个回调函数时,你要确保这个回调是被异步执行的。下面我们看一个例子,例子中的回调违反了这一原则:
function asyncFake(data, callback) {
if(data === 'foo') callback(true);
else callback(false);
}
asyncFake('bar', function(result) {
// this callback is actually called synchronously!
});
为什么这样不好呢?我们来看Node.js 文档里一段代码:
var client = net.connect(8124, function() {
console.log('client connected');
client.write('world!\r\n');
});
在上面的代码里,如果因为某种原因,net.connect()变成同步执行的了,回调函数就会被立刻执行,因此回调函数写到客户端的变量就永远不会被初始化了。
这种情况下我们就可以使用process.nextTick()把上面asyncFake()改成异步执行的:
function asyncReal(data, callback) {
process.nextTick(function() {
callback(data === 'foo');
});
}
- 用在事件触发过程中
EventEmitter有2个比较核心的方法, on和emit。node自带发布/订阅模式
var EventEmitter = require('events').EventEmitter;
function StreamLibrary(resourceName) {
this.emit('start');
}
StreamLibrary.prototype.__proto__ = EventEmitter.prototype; // inherit from EventEmitter
const stream = new StreamLibrary('fooResource');
stream.on('start', function() {
console.log('Reading has started');
});
function StreamLibrary(resourceName) {
var self = this;
process.nextTick(function() {
self.emit('start');
}); // 保证订阅永远在发布之前
// read from the file, and for every chunk read, do:
}
这次分享就先到这里,感谢阅读!
参考
- 事件循环:微任务和宏任务
- Node.js 事件循环,定时器和 process.nextTick()
- 深入理解NodeJS事件循环机制
- setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop
- setTimeout(callback[, delay[, …args]])
- 实际延时比设定值更久的原因:最小延迟时间