Timers 模块应该是 Node.js 最重要的模块之一了。为何这么说呢?html
在 Node.js 基础库中,任何一个 TCP I/O 都会产生一个 timer(计时器)对象,以便记录请求/响应是否超时。例如,HTTP请求常常会附带 Connection:keep-alive
这个请求头,以让服务器维持 TCP 链接,但这个链接显然不可能一直保持着,因此会为它设置一个超时时间,这在内部库里正是经过 Timers 实现的。node
因此能够确定地说,任何使用 Node.js 编写的 Web 服务,必定在底层涉及到了 Timers 模块。(以后咱们能够看到,Timers 模块的性能对于 Web 服务而言极其重要)git
另外,你的 Node.js 代码中也许会调用到诸如 setTimeout
、setInternal
、setImmediate
,这些方法在浏览器内通常是由浏览器内部实现,而 Node.js 中,这些方法都是由 Timers 模块提供的:github
// 浏览器中:
> setTimeout
//=> ƒ setTimeout() { [native code] }
// Node.js:
> setTimeout
//=> { [Function: setTimeout] [Symbol(util.promisify.custom)]: [Function] }复制代码
Timers 模块如此重要,因此了解它的运行机制和实现原理对咱们深入理解 Node.js 十分有帮助,那么就开始吧。算法
刚才已经提到,在 Node.js 里,setTimeout
、setInternal
、setImmediate
这些定时器相关的函数是由基础库实现的,那么它们究竟是怎么实现的呢?api
Node.js 底层经过 C++ 在 libuv 的基础上包裹了一个 timer_wrap
模块,这个模块提供了 Timer
对象,实现了在 runtime 层面的定时功能。浏览器
简单的说,你能够经过这个 Timer
对象实现一个你本身的 setTimeout
:bash
const Timer = process.binding('timer_wrap').Timer;
const kOnTimeout = Timer.kOnTimeout | 0;
function setTimeout(fn, ms) {
var timer = new Timer(); // 建立一个 Timer 对象
timer.start(ms, 0); // 设置触发时间
timer[kOnTimeout] = fn; // 设置回调函数
return timer; // 返回定时器
}
// 试一试
setTimeout(() => console.log('timeout!'), 1000);复制代码
固然,这个 setTimeout
方法不能真正地使用在生产环境中,由于它存在一个很严重的性能问题:服务器
每次调用该方法,都会建立一个全新的 Timer
对象。网络
设想一下,服务器每一秒都会面对成千上万的 TCP 或 HTTP 请求,为每一个请求都独立地建立一个 Timer
对象,这里的性能开销对于追求高并发的服务器端程序来讲,是不可接受的。
因此咱们须要一种更合理的数据结构来处理大量的 Timer 对象,Node.js 内部很是巧妙地使用了双向链表来解决这个问题。
下面的文章中,我会使用 timer
表示 Node.js 层面的定时器对象,例如 setTimeout
、setInterval
返回的对象:
var timer = setTimeout(() => {}, 1000);复制代码
大写字母开头的 Timer
表示由底层 C++ time_wrap
模块提供的 runtime 层面的定时器对象:
const Timer = process.binding('timer_wrap').Timer;复制代码
Node.js 会使用一个双向链表来保存全部定时时间相同的 timer
,对于同一个链表中的全部 timer
,只会建立一个 Timer
对象。当链表中前面的 timer
超时的时候,会触发回调,在回调中从新计算下一次超时的时间,而后重置 Timer
对象,以减小重复 Timer
对象的建立开销。
上面这两句话信息量比较大,第一次看不懂不要紧,接着往下看。
举个例子,全部 setTimeout(func, 1000)
的定时器都会放置在同一个链表中,共用同一个 Timer
对象。下面咱们来看 Node.js 是怎么作到的。
在程序执行最初,Timers 模块会初始化两个对象,用于保存链表的头部(看源码请点这里):
// - key = time in milliseconds
// - value = linked list
const refedLists = Object.create(null);
const unrefedLists = Object.create(null);复制代码
咱们在后文中称这两个对象为 lists
对象,它们的区别在于, refedLists
是给 Node.js 外部的定时器(即第三方代码)使用的,而 unrefedLists
是给内部模块(如 net
、http
、http2
)使用。
相同之处在于,它们的 key 表明这一组定时器的超时时间,key 对应的 value,都是一个定时器链表。例如 lists[1000]
对应的就是由一个或多个超时时间为 1000ms
的 timer
组成的链表。
当你的代码中第一次调用定时器方法时,例如:
var timer1 = setTimeout(() => {}, 1000);复制代码
这个时候,lists
对象中固然是空的,没有任何链表,Timers 便会在对应的位置上(这个例子中是lists[1000]
)建立一个 TimersList
做为链表的头部,而且把刚才建立的新的 timer
放入链表中(源码请点这里):
// Timers 模块部分源码:
const L = require('internal/linkedlist');
const lists = unrefed === true ? unrefedLists : refedLists;
// 若是已经有链表了,那么直接复用,没有的话就建立一个
var list = lists[msecs];
if (!list) {
lists[msecs] = list = createTimersList(msecs, unrefed);
}
// 略过一些初始化步骤......
// 把 timer 加入链表
L.append(list, timer);复制代码
你能够亲自试一试:
var timer1 = setTimeout(() => {}, 1000)
timer1._idlePrev //=> TimersList { ...... }
timer1._idlePrev._idleNext //=> timer1复制代码
正如咱们以前说到的,上面 setTimeout(() => {}, 1000)
这个定时器,会保存在 lists[1000]
这个链表中。
这个时候,咱们链表的状态是这样的:
假设咱们一段时间后又添加了一个新的、相同时间的 timer:
var timer2 = setTimeout(() => {}, 1000);复制代码
这个新的 timer2
会被加入到链表中, 和以前的 timer1
在同一个链表中:
timer1._idlePrev === timer2 // true
timer2._idleNext === timer1 // true复制代码
若是画成图的话就是相似这样:
(PS:咱们一直提到的这个双向链表,实际上是是独立于 Timers 模块实现的,你能够在这里看到它的具体实现代码:linkedlist.js)
用一张图来总结一下就是,Timers 模块是这样储存 timer的:
如今你已经知道了如何用双向链表保存 timer,接下来咱们看看 Node.js 是如何利用双向链表实现 Timer
对象复用的吧。
虽然咱们把定时时间相同的 timer
放到了一块儿,但因为它们的添加时间不同,因此它们的执行时间也不同。
例如,链表中的第 1 个 timer
是在程序最初设置的,而第 2 个 timer
是在稍后时间设置的,它们定时时间相同,因此位于同一链表中,但因为时间前后顺序,固然不可能同时触发。
在链表中,Node.js 使用 _idleStart
来记录 timer
设置定时的时间,或者理解为 timer
开始计时的时间。
var timer1 = setTimeout(() => {}, 100 * 1000) // 这里咱们把时间设长一些,防止定时器超时后被从链表中删除
timer1._idleStart //=> 10829
// 一段时间后(100秒以内),设置第二个定时器 = ̄ω ̄=
var timer2 = setTimeout(() => {}, 100 * 1000)
timer2._idleStart //=> 23333
// 此时它们依然在同一个链表中:
timer1._idlePrev === timer2 // true
timer2._idleNext === timer1 // true复制代码
画成图的话就是这样:
你可能会很奇怪,咱们的链表中最左侧这个奇怪的 TimersList
究竟是个啥?难道只是一个头部装饰品吗?
事实上,TimersList
就包含着咱们所要复用的 Timer
对象,也就是底层 C++ 实现的那个定时器,它承担了整个链表的计时工做。
等时间到 100 秒时,timer1
该触发了,这个时候会发生一些系列事情:
TimersList
对象中的 Timer
(也就是 C++ 实现的那个东西)触发回调,执行 timer1
所绑定的回调函数;timer1
从链表中移除;timer2
对应的回调),重置 Timer
,重复 1 过程。下面用图来描绘一下整个过程:
首先,在两个定时器添加以后:
100秒到了,此时 Timer
对象触发,执行 timer1
对应的回调函数:
而后 timer1
被移除出链表,从新计算下次触发时间,重设 Timer
,此时状态变为:
整个过程的源码请参考这里
这样,咱们便经过一个双向链表实现了 Timer
对象的复用,一个链表只须要一个 Timer
,大大提升了 Timers 模块的性能。
看到这里你可能会以为,上面提到的这些设计,都很理所固然,天经地义。
然而并不是如此,对于处理多个 timer
对象,熟悉算法的同窗确定能想到,咱们彻底能够只用一个 Timer
!
例如,咱们能够把全部 timer
都放在惟一一个链表中,每次建立 timer
时,都经过计算 timer
具体执行的时间,从而找到合适的位置,把新的 timer
插入到链表中:
好比,最初的链表是这样:
TimersList <-----> timer1 <-----> timer2 <-----> timer3 <-----> ......
1000ms后执行 1050ms后执行 1200ms后执行复制代码
此时咱们调用
var timer4 = setTimeout(() => {}, 1100);复制代码
timer4
应该插入到哪儿呢?一个线性查找即可以找到位置,即在 timer2
和 timer3
之间:
插入!
TimersList <-----> timer1 <-----> timer2 <-----> timer4 <-----> timer3 <-----> ......
1000ms后执行 1050ms后执行 1100ms后执行 1200ms后执行复制代码
熟悉算法的同窗看到线性查找确定马上意识到了,彻底能够用一个O(lgn)
的二叉树代替上面这个须要线性查找O(n)
的链表。
这样咱们不但能够只用一个 Timer
对象,并且能够用最佳的效率找到 timer
的插入位置,为何 Node.js 都 v9.0 了尚未使用这样的算法呢?
事实上,社区很早以前就已经尝试过二叉树或者时间片轮转调度算法,但这些算法实际的性能数据却不如上文提到的多链表实现,为何呢?
由于内部库里,如 net
、http
模块,为 timer
设定的超时时间基本是固定的,因此产生了一个经验性的假设:越晚建立的 timer
极可能越晚执行。
基于这个假设咱们再来看二叉树算法,因为假设的存在,每次插入新的 timer
,因为插入时间最晚,执行时间很可能也是最晚的,因此很容易进入树的最右侧,此时,咱们的二叉树便退化成了一个普通的链表。性能优点也不复存在。(平衡二叉树?那就更不可能了)
其实,Timers 模块的设计并不是一簇而就,它的历史也是十分有趣。早在 Node.js v0.x 的史前时代,以前提到的 refedLists
和 unrefedLists
这两个对象是分开实现的:对于第三方代码,使用的是多链表的结构;对于内部库,使用的是单一链表的结构。
你能够在这里看到当年是如何实现链表的线性查找插入的。
后来直到 2015 年的这个 PR 的提交才正式告别了以前的线性搜索:use unsorted array in Timers._unrefActive() #2540
如今,社区里又有人产生了新的关于优化 Timers 的想法:
Optimizing 'unreferenced' timers #16105
读懂了文章的你,有兴趣吗?