第1章:定时器深入——setTimeout与setInterval的实现原理

说到 Node.js 的异步编程,定时器绝对是个绕不开的话题。我刚开始接触 Node.js 时,觉得 setTimeout 不就是「延迟执行」嘛,有啥好研究的?直到有一次线上服务出了个诡异的 bug,我才意识到——嗯,这东西比我想象的复杂多了。

1.1 setTimeout 到底是怎么工作的?

先看个最简单的例子:

console.log('开始');
setTimeout(() => {
  console.log('延迟执行');
}, 1000);
console.log('结束');

输出顺序是「开始 → 结束 → 延迟执行」。这个大家都懂。但问题来了:setTimeout 真的是「精确延迟 1 秒」吗?

其实不是。setTimeout 只是告诉事件循环:「嘿,至少等 1000 毫秒后再执行这个回调」。注意这个「至少」——它不保证精确的 1000 毫秒,只保证不会少于 1000 毫秒。

为什么会这样?因为 Node.js 是单线程的。事件循环得先把当前执行栈里的代码跑完,再去检查定时器队列。如果前面有个耗时的同步操作,比如一个大循环,那定时器就会被「卡住」。

核心要点:setTimeout 的延迟时间是最小延迟,不是精确延迟。

1.2 事件循环中的定时器位置

我个人习惯把事件循环想象成一个「轮询机器」。它不停地在各个阶段之间切换:

  1. timers 阶段:执行到期的定时器回调
  2. pending callbacks 阶段:执行一些系统操作的回调
  3. idle, prepare 阶段:内部使用
  4. poll 阶段:获取新的 I/O 事件
  5. check 阶段:执行 setImmediate 回调
  6. close callbacks 阶段:执行关闭回调

定时器就在第一个阶段——timers 阶段。每次事件循环跑到这里,它会检查有没有到期的定时器。如果有,就执行对应的回调。

但这里有个坑:如果 poll 阶段阻塞了太久,timers 阶段就会被推迟。我在项目中遇到过这种情况——一个文件读取操作卡住了,导致后面的定时器全部延迟执行。排查了半天才发现是这个问题。

避坑指南:我曾经在写日志系统时,用 setTimeout 做定时刷盘。结果因为前面的数据库查询太慢,日志延迟了整整 3 秒才写入。后来改用 setImmediate 才解决。

1.3 setInterval 的「陷阱」

setInterval 看起来更简单——每隔一段时间执行一次。但你真的了解它吗?

看这段代码:

setInterval(() => {
  // 假设这个回调执行需要 2 秒
  heavyTask();
}, 1000);

你猜猜看,实际执行间隔是多少?

答案是:不固定。因为 setInterval 不会等回调执行完再计时。它只是「每 1000 毫秒把回调加入队列」。如果回调执行时间超过了间隔时间,就会出现「回调堆积」的情况。

说白了,setInterval 不关心你的回调跑多久。它只管按时往队列里塞任务。如果回调执行时间不稳定,整个节奏就乱了。

特性 setTimeout setInterval
执行次数 一次 多次
延迟保证 至少延迟指定时间 至少间隔指定时间
回调堆积 不会 可能发生
推荐场景 一次性延迟任务 周期性轻量任务

1.4 延迟时间精度问题

你想想看,如果 setTimeout(fn, 0) 真的是「立即执行」,那下面的代码会怎么输出?

setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
console.log('C');

输出是 C → A → B。即使延迟是 0,定时器回调也得等当前同步代码执行完。这就是事件循环的规则。

实际项目中,延迟精度受几个因素影响:

  • 系统时钟精度:大多数系统时钟精度在 1-15 毫秒之间
  • CPU 负载:高负载下,事件循环可能被阻塞
  • 嵌套定时器:Node.js 对嵌套超过 5 层的定时器会强制设置最小 4 毫秒延迟

小技巧:如果你需要高精度的定时任务,可以考虑用 process.hrtime() 或者第三方库 node-schedule。我一般在做性能测试时才会用到这些。

1.5 实际项目中的选择

说了这么多,到底该用哪个?我的建议是:

  • 一次性延迟任务 → setTimeout
  • 周期性任务,且回调执行时间可控 → setInterval
  • 需要精确控制执行顺序 → setImmediate
  • 需要取消定时器 → 保存返回值,调用 clearTimeout/clearInterval

我记得有一次重构一个消息推送服务,原来用的是 setInterval 做心跳检测。结果因为网络波动,回调执行时间不稳定,导致心跳包堆积。改成 setTimeout 递归调用后,问题就解决了。

嗯,这里要注意:用 setTimeout 模拟 setInterval 时,记得在回调末尾重新设置定时器。不然一旦出错,整个循环就断了。

function myInterval(fn, delay) {
  function wrapper() {
    fn();
    setTimeout(wrapper, delay);
  }
  setTimeout(wrapper, delay);
}

这样写的好处是:每次执行完回调再等 delay 时间,不会出现堆积问题。代价是——执行间隔变成了「回调执行时间 + delay」,而不是固定的 delay。

所以没有完美的方案,只有适合的方案。你得根据实际场景来权衡。

总结一下:定时器不是精确的计时器,它是事件循环的一部分。理解它在事件循环中的位置,比记住 API 更重要。下次遇到定时器不准的问题,先检查事件循环有没有被阻塞。