本章我们来讨论一下nodejs中的计时器,以及引申开来的定时执行和延期执行等任务执行管理方法。
概述
我们现在普遍使用的计算机系统,都是冯.诺伊曼架构的电子计算机系统,其最底层的指令循环执行的驱动来源,就是所谓的时钟信号发生器。它是一个电路或元件,负责产生稳定的时钟信号。这个时钟信号通过电路网络传输到硬件系统的各个部件,来驱动和协调这些部件的指令执行和工作。显然,这个时钟信号的频率越高,每秒钟内能够进行的操作就越多,理论上表现出来的系统处理能力就越强(图为时钟信号控制CPU执行和操作的示意)。
所以,对真正的软件运行,或者更底层的指令集和数据操作而言,真正有意义的就是这个时钟周期,而非真正的绝对意义上的时间。但作为一个高级语言,它的设计和表现形式,必须贴近于人类的思维方式。其表现出来的形式,就是程序执行的顺序和控制逻辑。
一般过程性的编程语言并不强调程序执行的时间性。但相对而言,作为一个事件驱动异步执行的编程语言,JS语言和其程序的运行,就需要强化和体现这个时钟性,或者其表象就是提供了计时器方法和相关的处理机制。我们将会看到,在在nodejs程序内部,会大量使用计时器来实现异步执行回调方法。
可能是顺便的,在时钟机制上,JS也可以方便的提供时间控制机制,比如延时执行和间隔执行等等。这些机制,在业务场景中,也是非常实用和有效的。
常用的计时器运行模式,主要包括立即执行、延迟执行和定期执行,下面分别讨论。这些计时器在nodejs(其实前端也是一样的)环境中,是系统级方法,无需声明或者引用,可以直接使用。
立即执行 setImmediate
Immediate是"立即"的意思。理解了CPU时钟执行的基本概念,就比较容易理解这个"立即"执行机制。就是它将会被安排到"下一次"事件循环中,使用异步的方式尽量的"立即"执行。我们来看以下代码:
js
console.log("Step A");
setImmediate(()=>console.log("Step C"));
process.nextTick(()=>console.log("Step B"));
console.log("Step D");
从代码执行的结果(A-D-B-C)来看,它会首先执行主顺序线程中的A和D,然后异步执行事件循环线程中的B和C。这里为了对比,我们增加了process.nextTick方法。从执行结果来看,虽然逻辑上nextTick在Immediate之后,但它会被先执行,说明它在事件循环中的优先级是比较高的。笔者认为,这可能是nextTick是process的直接方法,会在主顺序进程结束后直接执行,而Immediate需要先放入事件循环,等待循环检查后回调的原因。
另外,笔者认为,是不是可以不需要专门设计和定义这个setImmediate方法,而是直接使用setTimeout(cb)或者setTimeout(cb,0)这种形式?这样应该比较简单和容易理解。
延迟执行 setTimeout
延迟执行,可以定义一个延迟时间,到时间后触发回调执行,示例代码如下:
js
console.log("Start");
setTimeout(()=>console.log("Step A"), 1000);
setTimeout(()=>console.log("Step B"), 2000);
setTimeout(()=>console.log("Step C"), 500);
console.log("End");
指定延迟时间的单位是毫秒。其实这个执行的延迟时间并不是特别精确(误差大约在10ms左右),执行系统只是尽量保证这个延迟的准确性而已。
间隔执行 setInterval
JS提供了间隔执行的定时器方法,可以指定间隔时间触发回调执行,示例代码如下:
js
let i=0;
console.log("Start", Date.now());
setInterval(()=>console.log("Step ",i++, Date.now()), 2000);
需要注意,间隔执行的第一次执行,也在执行间隔时间定义之后。和timeOut一样,这个时间间隔,并不是特别精确。
计时器对象和clear方法
上面几个定时器方法是有返回值的,就是所谓的计时器对象。对于timeOut和Interval,可以通过调用对应的clear方法以这个这个计时器对象作为参数,取消或者中断定时执行。
示例代码如下:
js
let i=0;
console.log("Start", Date.now());
let timer = setInterval(()=>console.log("Step ",++i, Date.now()), 2000);
setTimeout(()=>clearInterval(timer),10000);
上面三个方法,都有对应的clear方法,分别是clearImmdiate、clearTimeout和clearInterval。由于定时器对象实例会占有一定的系统资源,所以,不用的计时器应该及时清理,以避免资源占用过多或者内存泄露。
常见应用场景
上面我们讨论了JS中的三种计时器的执行方式。在实际的应用开发活动中,我们就需要对这些方法进行灵活有效的组合使用,来满足各种各样业务的需求。下面列举几类。
- 同步转异步
使用setImmidate方法,可以将同步操作,转换为异步操作。通常目的是解除逻辑先后的耦合关系,或者避免阻塞主执行线程。
- 控制异步执行的逻辑顺序
在Node.js异步编程中,可以使用计时器来控制回调函数的执行顺序,来解决不同异步操作之间的依赖关系。
- 延时执行
使用setTimeout可以延迟执行某段代码,笔者在一些应用初始化中会用到。比如先启动数据库连接,然后延迟启动Web服务,保证Web请求时,数据库连接已经可以使用。
有些时候,延时执行是一种"工程"手段,而非业务需求。比如在前端,登录时为了防止用户频繁重新获取短信验证码,就使用一个计时器,来临时禁用获取验证码的按钮。这种控制方式,通常被称为"防抖"(是防止手抖的意思吗?)。
- 间隔执行
简单的间隔执行,可以通过直接设置和调用setInterval的方法来实现。
对于执行时间比较长,或者执行时间不可控的操作,这种定时执行可能会带来一些问题。可能会遇到前一次执行还未结束,时间间隔就已经到了的情况。这时有两种解决方案,一个是记录任务执行的状态,只有正常结束,下次执行才能够生效;另一种不是使用间隔执行,而是串联使用延时执行,即在上一次任务正常结束后,再新建一个延时执行任务,来保证下一次执行前的时间间隔。
间隔执行是一种常用并且简单的任务调度策略,它不需要其他的外部条件和状态检查,就可以独立工作。虽然可能经常会造成一些浪费和无效工作,但在很多场景下,设计和实现成本最低,也是可以接受的。
- 重试
重试本质上也是一种轮询操作,但增加了退出机制。也可以使用setInterval,通过设置合理的执行间隔,配合执行结果和状态检查,并使用clearInterval或者clearTimeout来终止和退出来实现。
- 流量控制
很多节流算法中,都需要使用计时器来进行控制。比如,漏斗算法,滑动窗口,都需要计时器来定时计数和管理。
- 时间点任务
在真实的业务场景中,还有一类常见的需求,就是需要在特定时间点或者周期性的时间点进行某些操作。这显然也需要通过计时器实现。
JS和nodejs并没有原生的提供时间点执行的功能,如果不借助外部程序库,只能通过基本控制方法组合实现。例如,使用一个计时器定时检查当前时间,和任务时间匹配后,启动任务执行。
时间点任务执行的核心问题,是确认执行,并且避免重复执行。首先需要理解,任何计算机中的"时间",都不是完全精确,也不可能是完全精确的。所以,这些需求,只能在工程上实现,才有可行性。然后就需要明确任务执行的时间精确性,来设计时间的检查机制。这个是成本和代价的问题,检查间隔时间设计的太小,计算资源浪费,影响程序性能;设计的太大,可能不能达到控制精度的要求。这个可能需要开发者根据业务要求和条件权衡设置。
为了更好的理解和说明,我们构想了一个简单的例子。我们有一个任务,需要在2012年12月21日11时14分35秒执行。但是这个任务的要求并不需要那么精确,到分钟即可。但要保证在这分钟内,任务必须执行,考虑到计时器不完全精确,我们就不能将检查周期直接定为1分钟,比如可以将其定为40秒。这样,无论我们什么时候启动程序,到那一分钟,都会有机会来检查当前时间,并执行任务。这里的问题就是,到了这一分钟内,计时器(肯定)会检查并匹配到两次。而任务的重复执行,也是我们不希望的。
一个简单的做法是引入一个转换机制,将当前时间,转换为一个时间值,并和当前时间的记录值进行比较。只有当时间值发生变化,并且匹配的时候,才触发执行该时间值对应的任务。
另外,对于完全的周期性任务,可以选择在本次任务执行的时候,同时设置下次任务的时间点,这样就不需要在初始化时设置所有的执行时间点,因为那可能是无限的。
- 时间轮算法
在一些真实的大规模电商业务当中,会有很多的时间点执行任务或者延时执行的需求,如各种状态通知、自动定时操作等等。由于需要处理的数据规模巨大,我们前面讨论的普通计时器已经不具有可实现性(计算量和资源消耗超载),需要寻找更高效可能的技术方案,如:时间轮算法。
时间轮算法其实也不是特别新鲜的技术,因为它已经在linux系统中有相关的应用。由于篇幅限制,我们这里只简单列举一下,其他相关详细的原理和实现,笔者将会另行撰文讨论。
相关npm
定时执行,在Web应用中是一个比较普遍和常见的需求,所以也有很多第三方库提供相关的实现和功能。由于一般的需求和场景都比较简单,笔者并没有经常性的使用这些库,觉得没有太大的必要性。这里简单例举几个:
- cron
可以使用Unix Cron定时执行程序的定时语法,来控制任务的执行。熟悉cron的开发人员,可以无缝迁移。
js
import { CronJob } from 'cron';
const job = CronJob.from({
cronTime: '*/3 * * * *',
onTick: function () {
console.log('You will see this message every second');
},
start: true,
timeZone: 'America/Los_Angeles'
});
这个配置,可以每隔三分钟执行一次操作。
- node-schdule
这个和cron的用法差不多。下面的任务,在每个小时的第15分钟执行。
js
const schedule = require('node-schedule');
const job = schedule.scheduleJob('15 * * * *', function(){
console.log('The answer to life, the universe, and everything!');
});
- pm2
是的,pm2也内置支持定期执行nodejs应用程序。特别适合于像数据处理、状态检查等系统运维类型的应用。它也使用cron语法。
shell
$ pm2 start app.js --cron-restart="0 0 * * *"
# Or when restarting an app
$ pm2 restart app --cron-restart="0 0 * * *"
这个设置,会让pm2在每天半夜12点,启动或者重新启动应用。
小结
本文讨论了nodejs开发中,有关于计时器原理和应用方面的内容。