共计 8111 个字符,预计需要花费 21 分钟才能阅读完成。
说到 Node.js
的事件循环网上已经有了很多形形色色的文章来讲述其中的原理,说的大概都是一个意思,学习了一段时间,对 Node.js
事件循环有了一定的了解之后写一篇博客总结一下自己的学习成果。
事件循环
在笔者看来事件与循环本身就是两个概念,事件是可以被控件识别的操作,如按下确定按钮,选择某个单选按钮或者复选框。每一种控件有自己可以识别的事件,如窗体的加载、单击、双击等事件,编辑框(文本框)的文本改变事件。
然而循环则是在 GUI
线程中包含有一个循环,然而这个循环对于开发者和用户来讲是看不见的,只有关闭了程序之后该循环才会结束。当用户触发了一个按钮事件之后,就会产生响应的事件,这些时间被加入到一个队列中,用户在前台不断的产生事件,然而后台也在不断的处理这些时间,在处理的时候被加入到一个队列中,由于主循环中循环的存在会挨个处理这些对应的事件。
而对于 JavaScript
来讲的话由于 JavaScript
是单线程的,对于一个比较耗时的操作则是使用异步的方法解决(Ajax…)。对于不同的异步事件来也是由不同的线程各司其职来处理的。
Node.js 中的事件循环
Node.js
的事件循环与浏览器的事件循环还是有很大的区别的,当 Node.js
启动后,它会初始化事件轮询;处理已提供的输入脚本(或丢入 REPL
,本文不涉及到),它可能会调用一些异步的API
函数调用,安排任务处理事件,或者调用process.nextTick()
,然后开始处理事件循环。
有一点是非常明确的,事件循环同样运行在单线程环境下,JavaScript
的事件循环是依靠于浏览器来实现的,然而 Node.js
则是依赖于 Libuv
来实现的。
根据 Node.js 官方介绍,每次事件循环都包含了 6 个阶段,对应到 Libuv
源码中的实现,如下图所示,图中显示了事件循环的概述以及执行顺序。
- timersj 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
- I/O callbacks:执行一些系统调用错误,比如网络通信的错误回调
- idle,prepare:仅 node 内部使用
- poll:获取新的 I / O 事件, 适当的条件下 node 将阻塞在这里
- check:执行 setImmediate() 的回调
- close callbacks:执行 socket 的 close 事件回调
下面是 Node.js
事件循环源代码:
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {uv__update_time(loop);
// timers 阶段
uv__run_timers(loop);
// I/O callbacks 阶段
ran_pending = uv__run_pending(loop);
// idle 阶段
uv__run_idle(loop);
// prepare 阶段
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
// poll 阶段
uv__io_poll(loop, timeout);
// check 阶段
uv__run_check(loop);
// close callbacks 阶段
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
假设事件循环进入到某一个阶段,及时在这期间其他队列中的事件已经准备就绪,也会先将当前阶段对应队列中所有的回调方法执行完毕之后才会继续向下执行,结合代码也是能够很好的理解的。不难可以得出在事件循环系统中回调的执行顺序是有迹可循的,同样也会造成事件阻塞。
var fs = require("fs");
fs.readFile('input.txt', function (err, data) {if (err){console.log(err.stack);
return;
}
console.log(data.toString());
});
fs.readFile('test.txt', function (err, data) {if (err){console.log(err.stack);
return;
}
console.log(data.toString());
});
console.log("程序执行完毕");
对于整个事件循环有个一个大概的认知之后,接下来针对每个阶段进行详细的说明。
timers
该阶段主要用来处理定时器相关的回调方法,当一个定时器超市后一个事件就会加入到该阶段的队列中,事件循环会跳转至这个阶段执行对应的回调方法。
定时器的回调会在触发后尽可能早的被调用,为什么要说尽可能早的呢?因为实际的触发事件可能要比预先设置的时间要长。Node.js
并不能保证 timer
在预设时间到了就会立即执行,因为 Node.js
对timer
的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。
I/O callbacks
在这个阶段中除了 timers、setImmediate
,以及close
操作之外的大多数的回调方法都位于这个阶段执行。例一个 TCP socket
执行出现了一些错误,那么这个回调函数会在 I/O callbacks
阶段来执行。名字会让人误解为执行 I/O
回调处理程序,然而一些常见的回调则会再 poll
阶段进行处理。
I/O callbacks
阶段主要经过如下过程:
- 检查是否有 pending 的 I / O 回调。如果有,执行回调。如果没有,退出该阶段。
- 检查是否有 process.nextTick 任务,如果有,全部执行。
- 检查是否有 microtask,如果有,全部执行。
- 退出该阶段。
poll
对于 Poll
阶段其主要的功能主要有两点:
- 处理 poll 队列的事件
- 当有已超时的 timer,执行它的回调函数
当事件循环到达 poll
阶段时,如果这时没有要处理的定时器的回调方法,则会进行如下判断:
- 如果
poll
队列不为空,则事件循环会按照顺序便利执行队列中的回调方法,这个过程是同步的。 -
如果
poll
队列为空则会再次进行判断- 若有预设的
setImmediate()
,事件循环将结束poll
阶段进入check
阶段,并执行check
阶段的任务队列 - 若没有预设的
setImmediate()
,那么事件循环可能会进入等待状态,并等待新事件的产生,这也是该阶段为什么被命名为poll
的原因。出了这些意外,该阶段还会不断的检查是否有相关的定时器超市,如果有就会跳转到timers
阶段,然后执行对应的回调方法
- 若有预设的
check
该阶段执行 setImmediate()
的回调函数。关于 setImmediate
是一个比较特殊的定时器方法,setImmediate
的回调则会加入到 check
队列中,从事件循环的阶段图可以知道,check
阶段的执行顺序是在 poll
之后的。
一般情况下,事件循环到达 poll
阶段后,就会检查当前代码是否调用了 setImmediate
方法,这个在叙述 poll
阶段的时候已经有提及了,如果一个回调函数是被 setImmediate
方法调用的,事件循环则会跳出 poll
阶段从而进入到 check
阶段。(这一段有点重复 …)
close
close
阶段是用来管理关闭事件,用于清理应用程序的状态。如程序中的 socket
关闭等都会加入到 close
队列中,当本轮事件结束后则会进入下一轮循环。
小结
对于事件循环来说每个阶段都有一个任务队列,当事件循环到达某个阶段的时候,讲执行该阶段的任务队列,知道队列清空或执行的对调到达系统上限后,才会转入到下一个阶段。当所有的阶段被执行一次后,事件循环则就完成了一个tick
。
process.nextTick
这是 Node.js
特有的方法,它不存在于任何浏览器(以及进程对象)中,process.nextTick
是一个异步的动作,并且让这个动作在事件循环中当前阶段执行完之后立即执行,也就是上面所说的tick
。
process.nextTick(() => {console.log("1")
})
console.log("2")
// 2
// 1
官方对于 process.nextTick
有一段很有意思的解释:从语义角度看,setImmediate
(稍后会说到)应该比 process.nextTick
先执行才对,而事实相反,命名是历史原因也很难再变。
然而对于 process.nextTick
来说该方法并不是事件循环中的一部分,但是它的回调方法确是由事件循环调用的,该方法定义的回调方法会被加入到 nextTickQueue
的队列中。相反地,nextTickQueue
将会在当前操作完成之后立即被处理,而不管当前处于事件循环的哪个阶段。
Node.js
对 process.nextTick
进行了限制,若递归调用 process.nextTick
当倒带 nextTickQueue
最大限制之后则会抛出一个错误。
function nextTick (i){while(i<9999){process.nextTick(nextTick(i++));
}
}
// Maxmum call stack size exceeded
nextTick(0);
既然说 process.nextTick
也是存在于队列中,那么其执行顺序也是根据程序所编写顺序执行的。
process.nextTick(() => {console.log(1)
});
process.nextTick(() => {console.log(2)
});
// 1
// 2
和其它回调函数一样,process.nextTick
定义的回调也是由事件循环执行的,如果 process.nextTick
的回调方法中出现了阻塞操作,后面的要执行的回调函数同样会被阻塞。process.nextTick
会在各个事件阶段之间执行,一旦执行,要直到 nextTickQueue
被清空,才会进入到下一个事件阶段,所以如果递归调用 process.nextTick
,会导致出现I/O starving
的问题,比如下面例子的 readFile
已经完成,但它的回调一直无法执行。
const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {endtime = Date.now()
console.log('finish reading time:', endtime - starttime)
})
let index = 0
function handler () {if (index++ >= 1000) return
console.log(`nextTick ${index}`)
process.nextTick(handler)
}
handler();
// nextTick 1
// nextTick 2
// ......
// nextTick 999
// nextTick 1000
// finish reading time: 170
process.nextTick() vs setImmediate()
seImmediate
方法不属于 ECMAScript
标准,而是 Node.js
提出的新方法,它同样将一个回调函数加入到事件队列中,不同于 setTimeout
和setInterval
,setImmediate
并不接受一个时间作为参数,setImmediate
的事件会在当前事件循环的结尾触发,对应的回调方法会在当前事件循环的末尾(check)执行。虽然它确实存在于某些浏览器中,但并未在所有浏览器中达到一致的行为,因此在浏览器中使用时,您需要非常小心。它类似于 setTimeout(fn,0)
代码,但有时会优先于它。这里的命名也不是最好的。
-
process.nextTick
中的回调在事件循环的当前阶段中被立即执行。 -
setImmediate
中的回调在事件循环的下一次迭代或tick
中被执行
本质上,它们两个的名字应该互相调换一下。process.nextTick()
的执行时机比 setImmediate()
要更及时 (上面有提过)。实施这项改变将导致很多npm
包无法使用。每天都有很多新模块被加入,这意味着每等待一天,就会有更多潜在的破坏发生。虽然他们的名字相互混淆,但将它们调换名字这种事是不会发生的(建议开发者在所有地方使用setImmediate
,这样程序更容易让人理解)。
仍然使用上述例子,若把 nextTick
替换成 setImmediate
会怎样呢?
const fs = require('fs')
const starttime = Date.now()
let endtime;
fs.readFile('text.txt', () => {endtime = Date.now()
console.log('finish reading time:', endtime - starttime)
})
let index = 0
function handler () {if (index++ >= 1000) return
console.log(`setImmediate ${index}`)
setImmediate(handler)
}
handler();
// setImmediate 1
// setImmediate 2
// finish reading time: 80
// ......
// setImmediate 999
// setImmediate 1000
这是因为嵌套调用的 setImmediate()
回调,被排到了下一次事件循环才执行,所以不会出现阻塞。
setImmediate vs setTimeout
定时器在 Node.js
和浏览器中的表现形式是相同的。关于定时器的一个重要的事情是,我们提供的延迟不代表在这个时间之后回调就会被执行。它的真正含义是,一旦主线程完成所有操作(包括微任务)并且没有其它具有更高优先级的定时器,Node.js
将在此时间之后执行回调。
-
setImmediate()
被设计在poll
阶段结束后立即执行回调 -
setTimeout()
被设计在指定下限时间到达后执行回调
setTimeout(function timeout () {console.log('timeout');
},0);
setImmediate(function immediate () {console.log('immediate');
});
// 结果一
// timeout
// immediate
/**-------- 华丽的分割线 --------**/
// 结果二
// immediate
// timeout
why?为什么会有两个结果,笔者在研究这里的时候也是有些不太明白,于是又做了第二个例子:
var fs = require('fs')
fs.readFile(__filename, () => {setTimeout(() => {console.log('timeout')
}, 0)
setImmediate(() => {console.log('immediate')
})
});
// 运行 N 次
// immediate
// timeout
- 如果两者都在主模块调用,那么执行先后取决于进程性能,即随机。
- 如果两者都不在主模块调用,那么
setImmediate
的回调永远先执行。
虽然结论得出来了,但是这又是为啥呢?回想一下文章上半段所叙述的事件循环。首先进入 timer
阶段,如果我们的机器性能一般,那么进入 timer
阶段时,1 毫秒可能已经过去了(setTimeout(fn,0)
等价于 setTimeout(fn,1)
),那么setTimeout
的回调会首先执行。如果没到一毫秒,那么我们可以知道,在 check
阶段,setImmediate
的回调会先执行。为什么 fs.readFile
回调里设置的,setImmediate
始终先执行?因为 fs.readFile
的回调执行是在 poll
阶段,所以,接下来的 check
阶段会先执行 setImmediate
的回调。我们可以注意到,UV_RUN_ONCE
模式下,事件循环会在开始和结束都去执行timer
。
练习题
阅读完本文章有什么收获呢?不如看下下面的代码,预测一下输出结果是什么样的。先不要急着看答案额 …
const fs = require('fs');
console.log('beginning of the program');
const promise = new Promise(resolve => {console.log('I am in the promise function!');
resolve('resolved message');
});
promise.then(() => {console.log('I am in the first resolved promise');
}).then(() => {console.log('I am in the second resolved promise');
});
process.nextTick(() => {console.log('I am in the process next tick now');
});
fs.readFile('index.html', () => {console.log('==================');
setTimeout(() => {console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);
setImmediate(() => {console.log('I am from setImmediate callback');
});
});
setTimeout(() => {console.log('I am in the callback from setTimeout with 0ms delay');
}, 0);
setImmediate(() => {console.log('I am from setImmediate callback');
});
// 输出结果
// beginning of the program
// I am in the promise function!
// I am in the process next tick now
// I am in the first resolved promise
// I am in the second resolved promise
// I am in the callback from setTimeout with 0ms delay
// I am from setImmediate callback
// ==================
// I am from setImmediate callback
// I am in the callback from setTimeout with 0ms delay
总结
对于本文中一些知识点任然有些模糊,懵懵懂懂,一直都在学习中,通过学习事件循环也看了一些文献,在其中看到了这一句话:除了你的代码,一切都是同步的
,我觉得很有道理,对于理解事件循环很有帮助。
-
Node.js
的事件循环分为 6 个阶段 -
process.nextTick
不属于事件循环,但是产生的回调会加入到nextTickQueue
-
setImmediate
和setTimeout
的执行顺序会受到环境所影响
文章略长若文章中有哪些错误,请在评论区指出,我会尽快做出修正。大家可以踊跃发言共同进步,交流。