
2147483647 毫秒是 Node 定时器能接受的最大延时,折合 24.855 天。给它 2147483648 毫秒,也就是比上限多 1 毫秒,行为会反转:不再等到上限,当场按 1 毫秒执行。下面这张表是本机实测,第二列是被截掉的那个值,第五列是它实际等了多久。
延时(ms) 说明 传入值 Node 存的 _idleTimeout 60ms 内触发? 实际耗时
----------------------------------------------------------------------------------------------------------------
0 零 0 1 是 1.4 ms
-1 负数 -1 1 是 1.5 ms
-0.5 负小数 -0.5 1 是 1.6 ms
0.4 不足 1 毫秒 0.4 1 是 0.1 ms
1 一毫秒 1 1 是 1.4 ms
1000 一秒 1000 1000 否
2147483646 上限减一 2147483646 2147483646 否
2147483647 上限本身 2147483647 2147483647 否
2147483648 上限加一 2147483648 1 是 1.4 ms
2147483648 2^31 2147483648 1 是 1.5 ms
2147483647.5 上限加半毫秒 2147483647.5 1 是 1.5 ms
4294967296 2^32 4294967296 1 是 1.6 ms
604800000 一周 604800000 604800000 否
2592000000 三十天 2592000000 1 是 1.6 ms
31536000000 一年 31536000000 1 是 1.5 ms
10000000000 一百亿毫秒 10000000000 1 是 0.4 ms
最该看的两行
上限本身和一毫秒之后,行为完全相反:2147483647 会真的等 24.8 天,2147483648 被落到 1 毫秒当场触发。_idleTimeout 在这两个值之间从 2147483647 掉到 1。
一周是 604800000 毫秒,还在范围内,所以周任务不会出事。三十天是 2592000000,超了,被当成 1 毫秒。一年更不用说。任何想跨月跑一次的定时器,都会在这里变成一个高速循环。
为什么是 1,不是 0
Node 把延时存进 32 位有符号整数,这是从浏览器时代带下来的实现细节,上限就是 int32 能表示的最大正整数。超出去的值不会饱和到上限,而是被换成 1,同时在 stderr 留一条警告:
(node:4546) TimeoutNegativeWarning: -1 is a negative number.
Timeout duration was set to 1.
(Use `node --trace-warnings ...` to show where the warning was created)
(node:4546) TimeoutOverflowWarning: 2147483648 does not fit into a 32-bit signed integer.
Timeout duration was set to 1.
(node:4546) TimeoutOverflowWarning: 2147483648 does not fit into a 32-bit signed integer.
Timeout duration was set to 1.
(node:4546) TimeoutOverflowWarning: 2147483647.5 does not fit into a 32-bit signed integer.
Timeout duration was set to 1.
(node:4546) TimeoutOverflowWarning: 4294967296 does not fit into a 32-bit signed integer.
警告里那句 does not fit into a 32-bit signed integer 就是全部原因。0 和负数也被归到 1 毫秒,用的是另一条 TimeoutNegativeWarning,文本不一样,好区分。上限加半毫秒这种小数,判的是整个值放不放得下,所以 2147483647.5 也照样溢出。
警告只写进 stderr,不影响退出码。日志收集只抓 stdout 的部署里,这个循环会跑起来却没有任何记录。
三个入口都一样
setInterval、timers/promises 的 setTimeout、AbortSignal.timeout 全都走同一套底层计时器。
--- setInterval 同样受这个上限约束吗 ---
setInterval(…, 1000 ) _idleTimeout = 1000
setInterval(…, 2147483647 ) _idleTimeout = 2147483647
setInterval(…, 2592000000 ) _idleTimeout = 1
--- timers/promises 的 setTimeout ---
sleep(1 ) -> 已到点 耗时 1.7 ms
sleep(2147483647 ) -> 还在等 耗时 60.7 ms
sleep(2592000000 ) -> 已到点 耗时 1.6 ms
sleep(2147483648 ) -> 已到点 耗时 1.5 ms
AbortSignal.timeout 这一组四个值都没抛错,也没提前 abort,所以它挡不住这个坑:
--- AbortSignal.timeout 的大数值 ---
AbortSignal.timeout(1000 ) 没有抛错,aborted = false
AbortSignal.timeout(2147483647 ) 没有抛错,aborted = false
AbortSignal.timeout(2147483648 ) 没有抛错,aborted = false
AbortSignal.timeout(2592000000 ) 没有抛错,aborted = false
长周期该怎么写
最省事的改法是分段续期:每次只安排不超过上限的一段,到点之后再排下一段。段长取一天,既远低于上限,也不会因为进程被反复唤醒而浪费。
const MAX = 2 ** 31 - 1; // 2147483647
const SEGMENT = 24 * 3600 * 1000; // 一天一段,必须 ≤ MAX
function sleepLong(ms) {
return new Promise((resolve) => {
let left = ms;
const step = () => {
const d = Math.min(left, SEGMENT);
setTimeout(() => {
left -= d;
if (left > 0) step();
else resolve();
}, d);
};
step();
});
}
把段长缩到 1200 毫秒、总时长 3000 毫秒跑一遍,能看清它是怎么拆的:
--- 分段续期实测(把一天一段缩成 1200 毫秒一段,跑 3000 毫秒)---
每段毫秒数: 1200 + 1200 + 600 ,共 3 段
实际耗时: 3006.2 ms
另一种是记账式:定时器固定短周期跑,比如每分钟醒一次,读记录里的 next_run 时间戳,到了才执行。多实例部署时这条路更合适,每次醒来都要重新抢一次执行权,进程重启也不会把计划丢掉。
分段续期我一般用在单机进程里的长延时任务,多实例的定时任务走记账式。系统调度最省心,代价是部署时多一份 cron 配置,本地开发环境里跑不起来。
真按天或按月跑的任务,交给系统调度更稳。setTimeout 在这里只是一个计时原语,不用它当调度器。
抖动的量级
同一个 1000 毫秒的定时器连做五次,实测每次都在 1 秒出头,误差是毫秒级:
--- 定时器触发时的随机抖动(同一个 1 秒定时器连做 5 次)---
第 1 次: 1001.3 ms
第 2 次: 1002.2 ms
第 3 次: 1002.3 ms
第 4 次: 1002.2 ms
第 5 次: 1001.8 ms
这个误差和前面那个从 24.8 天变成 1 毫秒的差别不在一个量级。触发得早或晚几十毫秒可以接受,被换成 1 毫秒不行。这类故障的表现是任务被高频重复触发,查日志时要先想到去看 stderr。
最后一点容易踩:上面那个 sleep(2147483647) 还挂着的时候,进程不会自己退出,事件循环里有一个 24.8 天后才到点的定时器撑着。写长周期脚本时如果发现命令跑完不返回,先看有没有这种没清掉的定时器。