侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 190 篇文章
  • 累计收到 7 条评论

延时写成 30 天,Node 只等了 1 毫秒

2026-10-14 / 0 评论 / 7 阅读

延时写成 30 天,Node 只等了 1 毫秒

Node 的定时器延时上限是 2147483647 毫秒,2 的 31 次方减一,折合 24.8551 天。官方文档在 setTimeout 的 delay 参数那一栏写着:大于 2147483647 或小于 1 的延时,会被设成 1。超出去的值没有被截到上限,它被换成了 1 毫秒。这次量的是 _idleTimeout:每个延时建一个定时器,读 Node 实际存了什么,再用一个 60 毫秒的小定时器看它有没有提前触发。

文档那一条,量出来是 24.8551 天

上限本身落在 2147483647 毫秒,也就是 32 位有符号整数能表示的最后一个正整数。比它小一毫秒的 2147483646 存下来还是 2147483646。到 2147483648 就掉头了,_idleTimeout 变成 1,那个 60 毫秒的观察定时器还没到点,它已经跑完。

== 3. 逼近 32 位上限的那几个值 ==
传入值                 说明            _idleTimeout     60ms 内触发   实际耗时
----------------------------------------------------------------------------------------
2147483646            上限减一            2147483646      否
2147483647            上限本身            2147483647      否
2147483647.5          上限加半毫秒          1               是           0.2 ms
2147483648            上限加一            1               是           1.4 ms
2147483649            上限加二            1               是           0.2 ms
4294967296            2^32            1               是           1.6 ms
2592000000            三十天             1               是           1.5 ms
31536000000           三百六十五天          1               是           1.6 ms
10000000000           一百亿毫秒           1               是           1.6 ms

换成天数看:2147483647 毫秒是 24.8551 天,三十天的 2592000000 和一年的 31536000000 都在表尾变成了 1。想写每月跑一次的定时任务,正好落在被砍掉的那一侧。

上限加半毫秒也算超了

文档还有一句:非整数的延时会被截断成整数。如果先截断再判上限,2147483647.5 应该被截成 2147483647,然后安安静静等 24.8 天。实测它的 _idleTimeout 是 1,说明上限这一关在取整之前就过了。正常范围内的 1000.4 和 1000.7 存下来还是带小数的原样。

== 2. 正常范围内的延时,对象里存下来的值 ==
传入值                 说明            _idleTimeout     60ms 内触发   实际耗时
----------------------------------------------------------------------------------------
1.5                   一点五毫秒           1.5             是           1.4 ms
999                   差一毫秒到一秒         999             否
1000                  一秒              1000            否
1000.4                一秒带小数           1000.4          否
1000.7                一秒带小数           1000.7          否
86400000              一天              86400000        否
604800000             一周              604800000       否

零和负数也被设成 1,警告名字不一样

0、0.4、0.5、-1、-100、-0.0001 存下来全是 1,60 毫秒内全部触发。文档那句「小于 1 会被设成 1」把它们盖住了。警告里能看出分支:负数打一条 TimeoutNegativeWarning,0 和不足 1 毫秒的小数什么都不打。

== 1. 零、负数、小数 ==
传入值                 说明            _idleTimeout     60ms 内触发   实际耗时
----------------------------------------------------------------------------------------
0                     零               1               是           1.3 ms
0                     负零              1               是           1.3 ms
0.4                   零点四毫秒           1               是           1.3 ms
0.5                   半毫秒             1               是           1.3 ms
1                     一毫秒             1               是           0.1 ms
-1                    负一              1               是           1.9 ms
-0.5                  负零点五            1               是           1.3 ms
-100                  负一百             1               是           1.3 ms
-0.0001               负万分之一           1               是           1.3 ms

负数、正溢出和 NaN 的警告文本不一样。

== 4. 每一类警告的原文 ==
负一                TimeoutNegativeWarning: -1 is a negative number. / Timeout duration was set to 1.
上限加半毫秒            TimeoutOverflowWarning: 2147483647.5 does not fit into a 32-bit signed integer. / Timeout duration was set to 1.
上限加一              TimeoutOverflowWarning: 2147483648 does not fit into a 32-bit signed integer. / Timeout duration was set to 1.
上限加二              TimeoutOverflowWarning: 2147483649 does not fit into a 32-bit signed integer. / Timeout duration was set to 1.
2^32              TimeoutOverflowWarning: 4294967296 does not fit into a 32-bit signed integer. / Timeout duration was set to 1.
三十天               TimeoutOverflowWarning: 2592000000 does not fit into a 32-bit signed integer. / Timeout duration was set to 1.
三百六十五天            TimeoutOverflowWarning: 31536000000 does not fit into a 32-bit signed integer. / Timeout duration was set to 1.
一百亿毫秒             TimeoutOverflowWarning: 10000000000 does not fit into a 32-bit signed integer. / Timeout duration was set to 1.
到这一步收到 8 条警告

那句 does not fit into a 32-bit signed integer 就是全部原因。警告只写进 stderr,退出码不受影响,只收 stdout 的日志系统里看不出异常。

一条警告在进程里发几次

三类警告的重复行为不一样,文档里没写。负数警告整个进程只发一次,换成别的负数也不再发。NaN 警告同样只发一次。正溢出警告每次都发。

== 5. 警告的重复次数不一样 ==
负数三种各调一次                          -> 新增 0 条
负数再调一次                            -> 新增 0 条
NaN、非数字字符串、空对象各一次                 -> 新增 1 条
NaN 再调一次                          -> 新增 0 条
正溢出三种各调一次                         -> 新增 3 条
正溢出再调一次                           -> 新增 1 条

第一行是 0 条,不是写错了:负数警告在更早的实验里已经发过。

字符串一千能用,BigInt 直接抛错

延时不是数字的时候,先转一次再判断。字符串 "1000" 存下来是 1000 毫秒,"1e3" 也一样;非数字字符串和空对象转出来是 NaN,落到 1。BigInt 转不过去,直接抛 TypeError: Cannot mix BigInt and other types,这句报错说明延时在被使用前做过一次乘法转换。

== 6. 非数字的延时怎么处理 ==
传入值                 说明           结果
----------------------------------------------------------------------------------------
undefined             undefined       _idleTimeout = 1         60 毫秒内触发 = 是
null                  null            _idleTimeout = 1         60 毫秒内触发 = 是
true                  布尔 true         _idleTimeout = 1         60 毫秒内触发 = 是
false                 布尔 false        _idleTimeout = 1         60 毫秒内触发 = 是
1000                  字符串一千           _idleTimeout = 1000      60 毫秒内触发 = 是
1e3                   字符串科学计数         _idleTimeout = 1000      60 毫秒内触发 = 是
abc                   非数字字符串          _idleTimeout = 1         60 毫秒内触发 = 是
NaN                   NaN             _idleTimeout = 1         60 毫秒内触发 = 是
Infinity              正无穷             _idleTimeout = 1         60 毫秒内触发 = 是
-Infinity             负无穷             _idleTimeout = 1         60 毫秒内触发 = 是
1000                  BigInt 一千       抛错 TypeError: Cannot mix BigInt and other types, use explicit conversions
[object Object]       空对象             _idleTimeout = 1         60 毫秒内触发 = 是
                      空数组             _idleTimeout = 1         60 毫秒内触发 = 是

从配置里读进来的字符串延时,能转成数字就照常生效,这比抛错更难查。

setInterval 走的是同一套校验

文档把 setTimeout 与 setInterval 的 delay 写在同一个参数说明里,实测两边一致:2147483647 原样留下,2147483648 变成 1。写错的周期值会让 setInterval 变成高频循环。

== 7. setInterval 有没有同样的上限 ==
setInterval(…, 1000          )  _idleTimeout = 1000
setInterval(…, 2147483647    )  _idleTimeout = 2147483647
setInterval(…, 2147483648    )  _idleTimeout = 1
setInterval(…, 2592000000    )  _idleTimeout = 1
setInterval(…, 31536000000   )  _idleTimeout = 1

nextTick 和 setImmediate 没有延时参数

这两个 API 不接受毫秒数,上面那条上限对它们不适用。差别在别的地方:nextTick 排在当前这次操作之后、进入下一个阶段之前;setImmediate 属于 check 阶段,在 poll 阶段结束后跑。把它们和 promise.then 放在同一个场景里排队,谁先谁后取决于代码跑在哪里。

== 8. nextTick 与 promise.then 谁先,看代码跑在哪里 ==
  ESM 顶层(就是本文件)                 -> promise.then, nextTick
  定时器回调里                        -> nextTick, promise.then
  I/O 回调里                       -> nextTick, promise.then
  await 之后                      -> promise.then, nextTick
  CommonJS 顶层(另起一个进程)           -> nextTick, promise.then

前四行都在同一个进程里,差别只在调用的位置。ESM 顶层和 await 之后是 promise.then 在前,定时器回调与 I/O 回调里换成了 nextTick 在前。

== 9. setImmediate 与 setTimeout(0) 的先后,另起进程连跑 20 次 ==
  setImmediate -> setTimeout0       18 次 / 20
  setTimeout0 -> setImmediate       2 次 / 20

setImmediate 与 setTimeout(0) 的先后是另一回事。二十次里十八次是 setImmediate 先,两次反过来,放进 I/O 回调里则稳定。把它当成「一定比 setTimeout(0) 早」的保证,在主脚本里靠不住。

== 10. I/O 回调里再排一次 setImmediate 与 setTimeout(0) ==
  I/O 回调里 -> setImmediate -> setTimeout0

十万次 nextTick 之后,0 毫秒定时器等了 11 毫秒

nextTick 队列不归事件循环的阶段管,排在当前操作之后马上清空。十万次递归排进去,后面的 0 毫秒定时器要等这一队走完才拿到事件循环。

== 11. 用 nextTick 排大队,0 毫秒的定时器被推后多久 ==
  先排 100000 个 nextTick,再等一个 0 毫秒定时器
  定时器实际等了 11.3 ms 才拿到事件循环

11 毫秒是这一台机器这一轮的量级,随负载会变。方向是确定的:延时参数写 0 也只是「尽快」,前面有多少活儿排队,它就得排在后面。

这条上限来自 32 位有符号整数,改动不了,只能绕开。我在长周期任务里不指望 setTimeout 撑满一个月,超过一天的部分改成按时段续期;真按天跑的东西交给系统调度更稳,代价是部署时多一份定时配置。

    🤞 分享