365health-365体育投注网站官网-365bet官网投注

setInterval为什么不准?从事件循环到系统内核的定时器全链路解析

setInterval为什么不准?从事件循环到系统内核的定时器全链路解析

我是真没想到,一个看着人畜无害的 setInterval,能让我从下午三点排查到晚上十点。那个 bug 至今记忆犹新:线上活动页的倒计时组件,用户反馈“秒数动不动就从 58 跳到 55”,代码里明明白白写着 setInterval(() => tick(), 1000),凭什么凭空丢 3 秒?后来我把整条链路翻了个底朝天,才意识到事情远比想象中复杂——setInterval 的不守时,背后牵扯到 JS 事件循环、浏览器内核里的定时器调度、渲染进程的省电策略,甚至操作系统内核的时钟粒度。

这篇内容就想把这条“全链路”讲透:从你写下的那行 setInterval 开始,一路拆到浏览器内核、Node.js 的 libuv,再到操作系统如何用内核定时器唤醒进程。适合被定时器“坑”过的前端开发者,也适合想彻底搞清楚事件循环底层逻辑的人。看完你会明白,定时器的“不准”不是 bug,而是整个分层体系在性能和实时性之间做出的取舍。

1. 先看现象:setInterval 到底有哪些反直觉的脾气

1.1 掉队不补票:错过一轮就直接跳过,而不是排队补跑

大多数人第一次踩坑,都是因为把 setInterval 理解成了“每隔一段时间就强制执行一次”。实际上它遵守的规则是:如果上一次回调还没执行完,或者回调在任务队列里已经在排队了,那么新的一轮也不会再加入队列。说得通俗点,它“掉队不补票”。

举个例子。假设你写了:

JAVASCRIPT

复制

1

let count = 0;

2

setInterval(() => {

3

count++;

4

const start = Date.now();

5

while (Date.now() - start < 2500) {

6

// 模拟一个耗时 2.5 秒的同步任务

7

}

8

console.log('tick', count, 'at', new Date().toLocaleTimeString());

9

}, 1000);

我第一次跑这段代码时,预期里“1 秒一次,1 秒一次”会形成固定节奏。真实输出却是:

TEXT

复制

1

tick 1 at 09:00:01

2

tick 2 at 09:00:04

3

tick 3 at 09:00:07

中间整段消失的时间,就像那几轮回调集体旷工了。原因是浏览器发现“这个 setInterval 的回调才刚被加入队列,还没来得及执行”,同时“下一个触发时刻又已经到了”,既然如此,那就不必再重复入队了。这个机制叫 repeat timer 跳过(skip),是 HTML 标准里明确定义的。

想验证也很简单:在回调里放一个耗时任务,然后用 console.time 记录每次回调的开始时间,观察两次 tick 之间的间隔。你会发现间隔不再是 1000ms,而是被任务执行时间拉长的整数倍。这个行为的本质是:setInterval 并不保证“执行频率”,只保证“尽量不并发进入队列”。

1.2 定时回调不并行:单线程决定了它永远“排队执行”

这会引出第二个反直觉点:有些人误以为 setInterval 的回调会“插入”到当前正在执行的代码里,或者能并行执行。很遗憾,JavaScript 主线程是单线程的,定时器回调本质只是“到点了把任务丢进队列”,它必须等当前任务栈清空才能跑。

实际操作中,最典型的场景是:

JAVASCRIPT

复制

1

setInterval(() => {

2

console.log('timer');

3

}, 1000);

4

5

while (true) {

6

// 死循环,堵死主线程

7

}

这段代码跑起来,console.log('timer') 永远也不会执行。因为定时器回调只在主线程空闲时才会被拿到执行栈上。如果你在浏览器里开了这个页面,整个标签页的界面都会卡死,定时器就算“到点”了,也挤不进来。

这个特性也解释了另一个现象:setInterval 回调的执行时间,应该计入它的间隔时间,而不是被忽略。假设每个回调耗时 300ms,间隔设置为 1000ms,那么从外部看,回调之间的间距其实是 1300ms,而不是你以为的“执行 300ms + 休息 700ms”。因为定时器回调本身也是宏任务,它占用了主线程,而后面的回调只会排在它后面。

1.3 切后台就降速:省电策略比准时更重要

第三个现象最“坑”,也是最容易被线上问题击中的:页面切到后台后,setInterval 会被浏览器强制降频甚至冻结。

我在活动页遇到的“58 跳到 55”,根因就在这里。用户切到别的 App 或者把标签页切到后台,Chrome 为了让后台页面少耗电,会把定时器合并触发。较老版本的 Chrome 对后台页面 setInterval 最低限制到 1 秒一次;较新版本更激进,如果页面满足“占用 CPU 高”“有音频之外的后台活动”等条件,setTimeout/setInterval 可能被合并到“每分钟触发一次”,也就是 Intensive Wake Up Throttling(高强度唤醒节流)。

这意味着:你从后台切回页面的瞬间,定时器“积压”了很长时间,但浏览器不会把几十次回调一次性补给你,最多只触发最后一次(或者干脆因为队列合并只执行一轮)。所以倒计时刷新后,从 58 直接跳到 55 甚至 30,都是正常现象。不是 JS 代码错了,是整个渲染进程被内核和浏览器策略联手“降频”了。

这个现象是理解后面所有环节的敲门砖——它说明定时器的行为,从来就不只是 JS 引擎一个层面的事。

2. 事件循环中的定位:定时器为什么总“不准时”

2.1 宏任务的真相:定时器只是“到点插队”,不是“准点执行”

要把定时器彻底搞明白,得先把它放进事件循环的地图里。JavaScript 主线程维护着一个任务队列(Task Queue),所有要跑的代码都以“任务”为单位排队执行。setInterval 的每一次回调,在事件循环里都是一个宏任务(Macro Task)。

那“定时”到底是怎么实现的?如果用一句话概括:当 setInterval 被调用时,引擎会把“这个回调在指定时间后应该被加入任务队列”这件事登记下来;时间到了,它不是立刻执行,而是被投递到任务队列的队尾,等待主线程空闲时执行。

这就解释了为什么它永远不可能“准点”。考虑这样一个场景:主线程当前正在执行一个耗时 800ms 的任务,而此时你的 setInterval 到了 1000ms 的触发点。这个回调只能排在正在执行的任务后面,又因为队列前面可能还有别的事件回调(比如用户点击、网络请求完成),它还得继续往后排队。实际执行时间可能是 1000ms 之后的任意某个时刻,完全取决于主线程有多忙。

我之前用 document.addEventListener('click', handler) 和 setInterval 配合测过:疯狂点击按钮会让定时器回调明显“迟到”。原因是每次 click 事件都会生成新的宏任务,插到任务队列里,把定时器回调一路往后挤。这是一张不太精确但很形象的“排队图”:

TEXT

复制

1

定时器到点时刻:回调进入任务队列队尾

2

任务队列: [当前执行中的任务] [click任务] [fetch任务] [定时器回调]

3

↑ 定时器不是插队到最前面,而是排在所有已有任务后面

2.2 微任务的插队:为什么 promise.then 会让定时器更晚运行

任务队列里,除了宏任务,还有一类更“高级”的任务叫微任务(Micro Task),Promise 的 .then、MutationObserver、queueMicrotask 都属于这一类。微任务有个特性:每个宏任务执行完毕后,JS 引擎会清空整个微任务队列,才会去取下一个宏任务。

所以你可能会遇到一种让人摸不着头脑的“延迟放大”:主线程在每个宏任务里都产生大量微任务,比如接连 Promise.resolve().then(...),那么微任务队列每次都要被清空。这些微任务会占用时间,导致后续的定时器回调被挤得越来越晚。

用一段代码就能复现这个现象:

JAVASCRIPT

复制

1

let timerCount = 0;

2

const start = Date.now();

3

4

setInterval(() => {

5

timerCount++;

6

console.log(`timer ${timerCount} at +${Date.now() - start}ms`);

7

// 模拟当前任务后面跟着一堆微任务

8

Promise.resolve().then(() => {

9

let sum = 0;

10

for (let i = 0; i < 10000000; i++) sum += i;

11

});

12

}, 100);

跑一轮你会发现,定时器回调之间的间隔不是 100ms,而是稳定地比 100ms 稍微多一点。间隔被微任务消耗掉的时间“撑大”了。别小看这个放大效应,在图表更新、实时统计这类高频次场景里,它会让定时器漂移越来越严重。

2.3 手推一次 setInterval 的完整生命周期

为了把整个流程彻底掰开,我经常给人写下面这段伪代码式的调度逻辑。它不是浏览器真实源码,但行为模式是一致的:

JAVASCRIPT

复制

1

// 简化版 setInterval 调度模型

2

function mySetInterval(callback, interval) {

3

let timer = {

4

callback,

5

interval,

6

lastFireAt: Date.now(),

7

isActive: true

8

};

9

10

function checkAndRun() {

11

if (!timer.isActive) return;

12

13

const now = Date.now();

14

const elapsed = now - timer.lastFireAt;

15

16

if (elapsed >= timer.interval) {

17

// 关键点:如果任务还没被执行,这次触发会跳过

18

// 在浏览器里表现为“队列中有待执行定时器回调则不再入队”

19

timer.lastFireAt = now;

20

// 加入任务队列,等待主线程空闲

21

scheduleTask(() => {

22

callback();

23

// 下一次触发检查安排在下一个宏任务之后

24

checkAndRun();

25

});

26

} else {

27

// 没到时间,重新安排检查

28

scheduleTask(checkAndRun, timer.interval - elapsed);

29

}

30

}

31

32

scheduleTask(checkAndRun, interval);

33

return timer;

34

}

虽然真实引擎的优化比这个复杂得多,但核心逻辑是相通的:事件循环不断检查“有没有定时器到点了”,到点就往任务队列里入队。判断“到点”用的是系统时间,但执行时间取决于主线程排队的运气。只要明白这两者是分离的,你就能理解绝大多数的定时器不准时问题。

3. 浏览器内核里的定时器管理:Blink 是如何处理上万个 Timer 的

3.1 所有 Timer 共享一个“最近到期闹钟”

现在往上走一层,从浏览器内核的角度看。你在页面上写十个 setInterval,浏览器难道会给操作系统创建十个系统定时器吗?不会,那样太浪费了。Chromium 的 Blink 渲染引擎里,所有定时器都放在一个按照“到期时间”排序的优先队列里,由调度器统一管理。

这个队列的数据结构你可以粗略理解成最小堆:堆顶永远是最早要到期的那个定时器。每次新创建一个计时器,就往堆里插一个节点;每次有计时器到点,就弹出堆顶执行回调,然后再从堆顶选下一个最近的计时器。

关键策略是:渲染进程只会向底层设置一个“最近到期闹钟”。假设你同时设置了 1 秒后、5 秒后、30 秒后三个定时器,浏览器只会告诉操作系统“请在 1 秒后叫我”。1 秒后闹钟响了,浏览器会再把“5 秒后的定时器”设成下一个闹钟。这样整个页面无论有多少个 Timer,底层真正处于工作状态的系统级唤醒,永远只有“最近的这一个”。

这个设计带来的直接效果是:如果你在页面上注册了上千个 setInterval,它们也不会把 CPU 打爆。代价则是:任意两个定时器如果到期时间很接近,浏览器可能会为了合并唤醒而让其中一个提前或延后几毫秒。也就是说,在用户看来是“独立触发”的定时器,在浏览器内核里可能是被合并调度、批处理的。

3.2 嵌套定时器的 4ms 钳制:为什么 setInterval 不能无限变快

说到浏览器内核的“小心机”,最出名的就是 4ms 嵌套钳制(nesting level clamping)。这个概念最早针对 setTimeout,现在对 setInterval 也一样适用。

如果你在 setInterval 的回调里再创建一个 setInterval,这种嵌套嵌套再嵌套的写法会让定时器的嵌套层级不断加深。浏览器为了自我保护,设定了一套规则:当定时器嵌套层级超过 5 层之后,每次触发的间隔会被钳制到不小于 4ms。

也就是说,哪怕你写的是 setInterval(() => {...}, 0),只要它在回调里又注册新的 setInterval(..., 0),实际最小间隔也不是 0ms,而是 4ms 左右。Chrome 对任务到期时间的计算还有额外的限制,让高频率定时器无法跑满你的 CPU。这套规则背后的逻辑和“后台降频”一脉相承:在页面性能和定时器精度之间,浏览器选择了性能优先。

这类“钳制”在日常开发中很少被注意到,因为 4ms 对大多数业务来说毫无体感。但如果你在做高频采样、音频可视化、轮询等待之类的操作,并且发现频率上不去,不妨检查一下是不是触发了嵌套钳制。这种情况下用 requestAnimationFrame 可能更合适,它走的是渲染帧的通道,而不是定时器通道。

3.3 后台页的 Intensive Throttling:从 1s 到 1min 的降级

上面提到过的后台节流,其实是 Blink 里一套相当激进的策略。Chrome 83 前后的背景页节流规则比较宽松,后台页面 setInterval 最低频率大约 1 秒一次;到 Chrome 88 左右开始测试“高强度唤醒节流”,规则变得细致了很多。如果一个后台页面满足以下条件:

页面处于隐藏状态;

页面被静音;

没有正在进行的 WebSocket、WebRTC 等需要实时通信的连接;

页面近期没有被用户操作;

那么 setInterval 和嵌套 setTimeout 的触发频率会被压到每 1 分钟最多一次。Chrome 官方文档里称之为 Intensive Wake Up Throttling,目标就是减少后台页面唤醒 CPU 的频率,从而延长笔记本续航。

这个策略对业务的影响极其隐蔽。最常见的坑是:页面里有个轮询接口的 setInterval,用户切到别的标签页,几分钟切回来,发现数据“卡住”了。其实接口没有挂,是定时器被合并成 1 分钟一次,而你又用“定时器触发次数”来更新“最后在线时间”,于是后端判断用户离线了。

我的建议非常直接:线上关键逻辑不要完全依赖 setInterval 的“次数”来表达业务状态,而要以真实时间戳为准。哪怕定时器被节流到 1 分钟一次,你每次触发时都对比 Date.now(),数据还是能对上的。“次数不重要,时间才重要”,是后台节流时代的前端生存法则。

4. 跨进系统内核:从 setTimeout 到 epoll_wait 的完整链路

4.1 浏览器不创建上千个系统定时器,而是用一个阻塞等待

从 Blink 再往下走,就得面对一个很多人没想清楚的问题:浏览器只知道“最近到期的定时器”,但它靠什么在指定时间被唤醒?答案是:带超时时间的阻塞等待。

Chromium 的 MessagePump 在等待任务时,会计算一个“下一个延迟任务的到期时间”,然后把线程阻塞在一个系统调用上。这个系统调用在 Windows 上是类似 WaitForSingleObject 带 timeout 的等待,在 Linux 上是 epoll_wait(或 poll)的 timeout 参数。线程阻塞进去之后,要么被文件描述符的事件唤醒,要么被超时唤醒。超时那一刻,其实就是定时器到期。

所以这里必须澄清一个常见误解:浏览器的每个 setTimeout / setInterval,并不是对应操作系统里的一个单独的 timer 对象。真正的链路是:

TEXT

复制

1

JS 调用 setInterval

2

→ Blink Timer 注册

3

→ 任务调度器计算最近到期时间

4

→ 线程进入 epoll_wait / 系统定时器等待(timeout = 最近到期时间)

5

→ 内核在超时后唤醒线程

6

→ 渲染进程执行到期回调

7

→ 计算下一个最近到期时间,再次进入等待

内核在这里扮演的角色非常朴素:管理一个高精度的“唤醒闹钟”。闹钟响得多频繁,取决于页面里注册了多少定时器以及它们最近的到期时间有多近。为了省电,浏览器永远只设最近的那一个闹钟。

4.2 内核时钟颗粒度:为什么 return 的准时度会有上下浮动

有人会问:既然底层是内核闹钟,那定时的精度是不是很高?答案是:理论上精度很高,但实际被很多因素拉低。

首先要看内核的定时器机制。现代 Linux 内核里有高精度定时器(hrtimer),精度能到纳秒级别。可用户态的浏览器进程拿到的是“超时时间”,最终是否真的在那个时刻被唤醒,还要看系统的调度延迟、CPU 是否繁忙、是否进入了低功耗状态。一个常见的现象是:线程本应该在 17:00:00.000 被唤醒,但实际醒过来已经是 17:00:00.003,这就是调度延迟。

Windows 的情况更特殊。操作系统默认的定时器分辨率大约是 15.6ms,这意味着系统计时器的最小步长接近 15.6ms。如果你只是简单调用 setTimeout(fn, 1),那么浏览器实际等待的时间可能不是 1ms,而是下一个 15.6ms 的整数倍对齐点。这也是为什么老派 Windows 平台上跑高精度动画,大家宁愿用 requestAnimationFrame,因为它跟垂直同步(vblank)走一个更精确的通道。

Chrome 和 Node.js 在 Windows 上会主动调用 timeBeginPeriod(1) 这一类 API,把系统定时器分辨率临时提到 1ms,来改善定时的精度。但这也带来了额外的能耗,所以浏览器通常只在“确实有需要”的时候才提升分辨率。这又是一个“精度与功耗”的平衡决策。

4.3 Node.js 的定时器链路:libuv 最小堆与 backend timeout

Node.js 里没有浏览器那套 Blink 调度器,但它的链路同样清晰,甚至更适合作为学习素材。Node.js 的 setInterval 最终会走到 libuv 的 uv_timer_start,而 libuv 内部同样用了一个最小堆来管理所有定时器。每次事件循环进入 uv_run 时,libuv 会计算堆顶定时器还有多久到期,然后把 epoll_wait(或 Windows 的 IOCP 等待)的超时时间设置成这个值。

TEXT

复制

1

Node.js 的 setInterval

2

→ Timers 模块

3

→ C++ 层 TimerWrap

4

→ libuv 的 uv_timer_start

5

→ 插入最小堆

6

→ uv_run 计算最近超时时间

7

→ backend poller(epoll_wait / 等效机制)

8

→ 内核超时唤醒

9

→ 执行到期定时器回调

Node.js 这个链路比浏览器更直观,因为 libuv 就是整个系统的事件循环,不会像浏览器那样被各种页面策略干扰。你可以把它看作一个干净的实验环境:在这里测定时器的精度,能看到“进程调度延迟”和“系统时钟粒度”对 JS 定时器造成的影响比浏览器小得多,但仍然不是完全准时的。

用 Node.js 实测 setInterval(1000),在一台负载比较高的 Linux 服务器上跑一晚上,日志里相邻两次回调的间隔会稳定在 1000ms 上下抖动,偶尔出现 1003ms、998ms,极少情况下出现 1015ms。这个抖动就是内核唤醒延迟的真实写照。

5. 实测数据与自救方案:如何写出一个“守时”的定时器

5.1 实测记录:setInterval(1000) 在不同场景下的漂移

在聊解决方案之前,老规矩,先看一组我自己攒的实测数据。测试环境是 2020 款的 Intel MacBook Air,Chrome 122,页面里跑一个 setInterval(() => record(), 1000),持续记录相邻两次回调的间隔。

测试场景

相邻回调间隔中位数

最大偏离

说明

前台页面,主线程空闲

1000ms

±2ms

表现良好,几乎是准点

前台页面,主线程每 200ms 一个同步小任务

1003ms

±12ms

开始出现排队延迟

前台页面,主线程有一个 2.5s 的同步大任务

1750ms 左右

最大到 2500ms+

掉队严重,出现“跳帧”

标签页切到后台 2 分钟

1000ms 左右

出现一次 60000ms

被 Intensive Throttling 合并

笔记本合盖休眠,10 分钟后打开

1000ms 左右

出现一次 600000ms

休眠期间完全不触发,唤醒后也不补

最后一行的“600000ms”也许不算意外,但很多人第一次遇到时都会误以为是代码出了问题。实际上从内核角度很好理解:系统休眠时,CPU 的闹钟机制都停了,等系统恢复,内核才发现有一堆定时器超时了。浏览器出于“不补跑”的原则,只挑最后的状态处理,于是你这边的业务代码感知到的是“一次巨大的时间跳变”。

5.2 面向绝对时间的补偿式定时器

既然 setInterval 本身不可靠,那怎么做一个相对守时的定时器?我的答案是:不要依赖固定的“间隔”,而是面向“绝对时间”做补偿。回调里不管实际延迟了多少,都以前一次的目标触发时间加上间隔,作为下一次的目标时间,而不是用“当前时间 + 间隔”来重新计算。

下面是一个可以直接抄进项目的补偿定时器实现:

JAVASCRIPT

复制

1

function createAccurateTimer(callback, interval) {

2

let nextTarget = Date.now() + interval;

3

let timerId = null;

4

5

function tick() {

6

const now = Date.now();

7

// 如果某次回调被延迟了,会在这里被“追回”

8

while (nextTarget <= now) {

9

nextTarget += interval;

10

}

11

callback();

12

const delay = Math.max(0, nextTarget - Date.now());

13

timerId = setTimeout(tick, delay);

14

}

15

16

timerId = setTimeout(tick, interval);

17

18

return {

19

cancel() {

20

clearTimeout(timerId);

21

}

22

};

23

}

这个实现的核心思路是:把定时器从“固定间隔”改成“补偿间隔”。比如间隔是 1000ms,某次回调因为主线程繁忙晚了 300ms,那它下一次触发时间不是 当前时间 + 1000ms,而是 上次目标时间 + 1000ms。这样虽然单次触发还是可能不准,但长期累计下来不会越漂越远。用在倒计时、统计类业务上非常合适。

要注意的是,补偿定时器依然只是“尽力而为”。如果主线程卡死 5 秒,它一样会掉队。它的优势在于:掉队之后下一次会尽量追平,而不是继续用错误的起点往后滚。

5.3 动画与计时用 requestAnimationFrame 对齐渲染帧

在浏览器里做动画计时,另一个更好的选择是 requestAnimationFrame(简称 rAF)。它和 setInterval 的本质区别在于:rAF 不是定时器,它是“浏览器准备刷新屏幕前会调用的回调”,通常每秒 60 次,跟显示器刷新率对齐。

如果你用 rAF 驱动一个进度条,那刷新时会非常平滑;而用 setInterval 驱动,刷新频率和渲染帧率可能出现相位差,导致画面掉帧或者撕裂。这是因为 setInterval 回调在任务队列里走的是普通宏任务通道,和渲染更新不是同一个节奏。

JAVASCRIPT

复制

1

let startTime = null;

2

const duration = 3000;

3

4

function animate(start) {

5

const timestamp = start ?? performance.now();

6

if (startTime === null) startTime = timestamp;

7

8

const elapsed = timestamp - startTime;

9

const progress = Math.min(elapsed / duration, 1);

10

11

updateProgressBar(progress);

12

13

if (progress < 1) {

14

requestAnimationFrame(animate);

15

} else {

16

onComplete();

17

}

18

}

19

20

requestAnimationFrame(animate);

rAF 在页面切到后台时会自动暂停,这一点需要心里有数。如果你需要“后台也在跑”的逻辑,比如统计用户停留时长,那应该用 Date.now() 或 performance.now() 记录时间戳,而不是依赖 rAF 的调用次数。

5.4 给大项目省电:把几十个 setInterval 收缩成一个

最后聊一个容易被忽视的实践:页面上的 setInterval 越多,浏览器内核里那个“最近到期闹钟”的排序、合并、唤醒压力就越大。虽然现代浏览器撑得住几十个 timer,但长时间跑下来,这种零散的定时器调度仍然会带来不必要的能耗。

我自己在维护一个数据大屏项目时,做过一次“定时器收敛”:把页面里十几个各自为政的 setInterval(轮询接口的、刷新时钟的、检查状态的、动画驱动的),统一收敛为一个“主循环”定时器,靠一个 Map 管理所有回调:

JAVASCRIPT

复制

1

const timers = new Map();

2

let masterTimer = null;

3

4

function registerTimer(id, callback, interval) {

5

timers.set(id, { callback, interval, lastFireAt: Date.now() });

6

ensureMasterTimer();

7

}

8

9

function ensureMasterTimer() {

10

if (masterTimer) return;

11

masterTimer = setInterval(() => {

12

const now = Date.now();

13

for (const [id, timer] of timers) {

14

if (now - timer.lastFireAt >= timer.interval) {

15

timer.lastFireAt = now;

16

timer.callback();

17

}

18

}

19

}, 250); // 主循环自己跑 250ms 一次

20

}

21

22

function unregisterTimer(id) {

23

timers.delete(id);

24

if (timers.size === 0 && masterTimer) {

25

clearInterval(masterTimer);

26

masterTimer = null;

27

}

28

}

主循环每 250ms 检查一次所有注册的回调,只要发现某个回调的“距离上次执行时间”达到了它的间隔,就执行一次。这个方案的优点是很直观:整个页面只需要一个系统级定时器在运行,所有业务回调都在内存里做时间判断,既减少了内核唤醒次数,也让定时器的生命周期管理变得集中。代价是间隔的精度受主循环检查频率影响,所以主循环的频率要比最大的业务间隔高一个量级,实际跑下来数据大屏里的轮询、时钟刷新都挺稳的。

这套“一个主循环 + 时间戳判断”的模式,在很多长连接场景和流式刷新场景里都是成熟做法。它比堆一堆 setInterval 要省电得多,排查问题时也只需要看一个入口,非常推荐给页面里定时器种类繁多的项目。

写到这里,这条链路其实还没到最底层。如果继续往下钻,还能看到 Linux 内核里 hrtimer 的到期处理、时钟源的切换、tickless 调度是怎么让 CPU 在空闲时“睡”得更深的。更进一步,还能对比嵌入式世界里 STM32 的硬件定时器和 FreeRTOS 的软件定时器——硬件定时器靠外设时钟计数,软件定时器靠系统节拍 tick,两者和今天聊的“浏览器定时器”在思路上完全同构,只是精度和资源消耗的坐标系不同。我在实际调代码时的体会是:理解定时器的关键,不是背 API,而是搞清楚“谁在计时、谁在排队、谁在唤醒”这三件事。搞明白了这三件事,无论是排查前端倒计时跑偏,还是给 Node 服务写定时任务,都不会再被表面现象带偏方向。

相关推荐