第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 事件循环中的定时器位置
我个人习惯把事件循环想象成一个「轮询机器」。它不停地在各个阶段之间切换:
- timers 阶段:执行到期的定时器回调
- pending callbacks 阶段:执行一些系统操作的回调
- idle, prepare 阶段:内部使用
- poll 阶段:获取新的 I/O 事件
- check 阶段:执行 setImmediate 回调
- 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 更重要。下次遇到定时器不准的问题,先检查事件循环有没有被阻塞。