提及事件循环(Event Loop), 想必大家第一反映就是浏览器中的事件循环、宏任务、微任务、任务队列等概念。这些设计就是用来解决js单线程带来的异步问题。不清楚的小伙伴, 可以翻阅现代JavaScript教程中的相关介绍, 个人感觉讲的还是很不错的。
但是今天想和大家分享的Nodejs事件循环, 和浏览器的事件循环完全不一样。
上图就是一轮事件循环所要经历的六个阶段。 需要注意的是,这幅图只是各阶段的排序图, 但是实际事件循环在执行回调时,会因回调的触发时机不同,导致执行顺序不同。
timers (定时器)
poll(轮询)
check(检查)
在事件循环的每次运行之间, Node.js会检查它是否在等待异步I/O或定时器, 如果没有的话就会自动关闭.
如果event loop进入了 poll阶段,且代码未设定timer,将会发生下面情况:
- 如果poll queue不为空,event loop将同步的执行queue里的callback,直至queue为空,或执行的callback到达系统上限;
- 如果poll queue为空,将会发生下面情况:
- 如果代码已经被setImmediate()设定了callback, event loop将结束poll阶段进入check阶段,并执行check阶段的queue (check阶段的queue是 setImmediate设定的)
- 如果代码没有设定setImmediate(callback),event loop将阻塞在该阶段等待callbacks加入poll queue,一旦到达就立即执行
如果event loop进入了 poll阶段,且代码设定了timer:
- 如果poll queue进入空状态时(即poll 阶段为空闲状态),event loop将检查timers,如果有1个或多个timers时间时间已经到达,event loop将按循环顺序进入 timers 阶段,并执行timer queue.
是不是看了有点上头?没关系, 我来画一张图:
我们以poll阶段为当前参考对象,把事件循环想象成排队打疫苗,那么现在这个队伍就有3个主要参与者(其他暂时忽略):1. timers, 2.poll, 3. check。
假设排在第一个的timers迟到了,还没有来(代码未设定timers,即定时器内的回调事件还没到, 或没有设置定时器)
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阶段')
})
打印结果
‘check阶段’
readFile
…
readFile
setTimeout
执行过程:
1 执行setTimeout, (将在10ms后将回调放入timers)
2 事件循环进入到poll阶段,开始不断的轮询监听事件
3 poll阶段 执行someAsyncOperation事件, 开始读取文件(2ms后执行回调)
4 执行setImmediate, 结束poll阶段, 进入check阶段, 打印"check阶段"
5 第一轮事件循环结束, 开始第二轮事件循环
6 因为时间仍未到定时器截止时间(现在过去2ms),所以事件循环又一次进入到poll阶段,进行轮询
7 poll阶段 队列里有fs的回调, 所以打印"readFile … readFile" 一直阻塞到20ms后结束, (在这过程中, 10ms的setTimeout回调时无法执行的)。
8 第二次事件循环结束, 进入到下一轮事件循环,此时发现timer事件队列已经添加了setTimeout的回调,所以开始打印"setTimeout"
当然, 如果setTimeout时间阈值改为2ms, 读取文件的事件改为10ms, 那么结果就是:
‘check阶段’
‘setTimeout’
‘readFile … readFile’
一个很有意思的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()不在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');
})
});
第一次timers为空 进入轮询阶段(poll 为空), 此时执行setImmediate 进入check阶段, 但是在事件循环切换阶段会执行process.nextTick所以, 会先打印 ‘nextTick1’ 和 ‘nextTick2’
check阶段, 1. 执行’setImmediate’, 2. 遇到异步的process.nextTick (放入i/o线程)>第二次event loop:
从上一次check阶段 进入第二次事件循环的timers阶段, 但是此时会在切换时执行process.nextTick, 所以, 依然会先打印 ‘nextTick3’
再执行setTimeout
结果:
nextTick1
nextTick2
setImmediate
nextTick3
setTimeout
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');
});
}
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:
}
这次分享就先到这里,感谢阅读!