在 JavaScript 中,setTimeout
是最常用函数之一,它允许开发者在指定的时间后执行一段代码。
但是需要注意的是,setTimeout
并不是 ECMAScript
标准的一部分,不过几乎每一个 JS 运行时都支持了这个函数。
HTML5 标准 中规定了 setTimeout
的具体行为。有同学可能听说过 setTimeout
的最小时延为 4ms,这是正确的,但是只正确了一部分正确。
在 HTML5 标准中,有如下规定:
4.If timeout is less than 0, then set timeout to 0.
5.If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
也就是说:如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms。chrome
中的 setTimeout
的行为基本和 HTML5 的标准一致。为什么说基本一致呢?因为 HTML 标准中是嵌套 >5 时设置最小延时,不过 chrome 的实现是 >=5 时设置最小延时。参考下面这个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// test.js
let start = Date.now();
let times = [];
setTimeout(function run() {
const timeout = Date.now() - start;
// 20ms 结束
if (timeout > 20) {
console.log(times);
console.log("调用次数:", times.length);
return;
}
times.push(timeout);
// 否则重新调度
setTimeout(run);
});
|
在 chrome
浏览器中的输出为:
1
2
|
// [0, 0, 0, 0, 5, 10, 15, 20]
// 调用次数: 8
|
可以看到前 4 次调用的 timeout 都是 0ms,后面的间隔时间都超过了 4ms;
在一些其他的 JS 运行时中,例如 nodejs
、deno
、bun
,其行为也不和 HTML5 标准中的规定一致。
不同运行时的 setTimeout 行为
在 nodejs:v16.14.0
中,上面例子的输出为:
1
2
3
4
5
6
7
8
|
// node test.js
// [
// 1, 3, 4, 5, 7, 8,
// 9, 10, 11, 13, 15, 16,
// 17, 19, 20
// ]
// 调用次数: 15
|
可以发现 nodejs 中并没有最小延时 4ms 的限制,而是每次调用都会有 1ms 左右的延时。
在 deno:v1.31.2
中,上面例子的输出为:
1
2
3
4
5
6
7
|
// deno run test.js
// [
// 3, 4, 5, 6,
// 7, 8, 14, 20
// ]
// 调用次数: 8
|
deno 嵌套超过 5 层后有最小延时 4ms 的限制,但是前面的 4 次调用的 timeout 也都有 1ms 左右的延时;
在 bun:v0.5.7
中,上面例子的输出为:
1
2
3
4
5
6
7
8
|
// bun run test.js
// [
// 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
// ...,
// ...
// ]
// 调用次数: 73561
|
bun 在短短 20ms 中竟然调用了 7 万次 setTimeout;事实上,目前 bun 中的 setTimeout 没有延时设置,调用次数基本就是事件循环次数;
为什么有以上的种种差异,这需要深入这些运行时的源码,来探究 setTimeout 的具体实现。
setTimeout 在各个运行时中的实现
chromium
在 chromium:v100.0.4845.0
中,setTimeout
延时限制的代码在 Blink 引擎中的 DOMTimer 类的构造函数中,源码在 /dom_timer.cc ,关键代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// third_party/blink/renderer/core/frame/dom_timer.cc
constexpr int kMaxTimerNestingLevel = 5;
constexpr base::TimeDelta kMinimumInterval = base::Milliseconds(4);
DOMTimer::DOMTimer(ExecutionContext* context,
ScheduledAction* action,
base::TimeDelta timeout,
bool single_shot,
int timeout_id)
: ExecutionContextLifecycleObserver(context),
TimerBase(nullptr),
timeout_id_(timeout_id),
nesting_level_(context->Timers()->TimerNestingLevel()),
action_(action) {
...
if (nesting_level_ >= kMaxTimerNestingLevel && timeout < kMinimumInterval)
timeout = kMinimumInterval;
...
}
|
其中,如果嵌套层数 nesting_level_
大于或等于一个常量 kMaxTimerNestingLevel
,并且定时器的时间间隔 timeout 小于另一个常量 kMinimumInterval
,则将 timeout 设置为 kMinimumInterval
。这个操作的目的是为了防止嵌套的定时器在短时间内反复触发,从而导致性能问题。
想象一下如果浏览器允许 0ms,会导致 JavaScript 引擎过度循环,那么可能网站很容易无响应。因为浏览器本身也是建立在 event loop 之上的。
nodejs
在 nodejs:v16.14.0
中,setTimeout
延时限制的代码在 lib/internal/timers.js 中,关键代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// lib/internal/timers.js
// Timeout values > TIMEOUT_MAX are set to 1.
const TIMEOUT_MAX = 2 ** 31 - 1;
function Timeout(callback, after, args, isRepeat, isRefed) {
after *= 1; // Coalesce to number or NaN
if (!(after >= 1 && after <= TIMEOUT_MAX)) {
after = 1; // Schedule on next tick, follows browser behavior
}
initAsyncResource(this, "Timeout");
}
|
在 Timeout 函数内部,会将 after 转换为数字类型,如果 after 大于 2 ** 31 - 1
或者小于 1
,则会将定时器的时间间隔为 1 毫秒。
这和上面 demo 中每次调用都会有 1ms 左右的延时的行为是一致的。
deno
在 deno:v1.31.2
中,setTimeout
入口文件在 ext/node/polyfills/internal/timers.mjs, 在该文件中引用了 ext:deno_web/02_timers.js,实现延时限制关键代码在 initializeTimer
函数中,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
function initializeTimer(
callback,
timeout,
args,
repeat,
prevId,
) {
...
if (timeout < 0) timeout = 0;
if (timerNestingLevel > 5 && timeout < 4) timeout = 4;
...
runAfterTimeout(
() => ArrayPrototypePush(timerTasks, task),
timeout,
timerInfo,
);
return id;
}
function runAfterTimeout(cb, millis, timerInfo) {
const cancelRid = timerInfo.cancelRid;
const sleepPromise = core.opAsync("op_sleep", millis, cancelRid);
...
}
|
可以看到在 initializeTimer
中会检查定时器的时间间隔是否小于 0,如果是,则将其重置为 0。如果定时器嵌套层数大于 5ms 并且时间间隔小于 4ms,也会将时间间隔重置为 4ms。
之后会将处理好的值传入 runAfterTimeout
函数中,该函数会调用 core.opAsync
方法,这会调用到 deno 中 Rust 的 op_sleep
函数,该函数具体位置在 ext/web/timers.rs 中:
1
2
3
4
5
6
7
8
9
10
11
12
|
#[op(deferred)]
pub async fn op_sleep(
state: Rc<RefCell<OpState>>,
millis: u64,
rid: ResourceId,
) -> Result<bool, AnyError> {
let handle = state.borrow().resource_table.get::<TimerHandle>(rid)?;
let res = tokio::time::sleep(Duration::from_millis(millis))
.or_cancel(handle.0.clone())
.await;
Ok(res.is_ok())
}
|
可以看到在 op_sleep
函数中,会调用 tokio::time::sleep
方法,该方法是 tokio
库中的方法,该库是 Rust 中的异步编程库,可以参考 tokio 官网。
所以 deno 中 setTimeout
的延时限制是通过 Rust tokio
库实现的。该库的延时粒度是毫秒级别的,实现是特定于平台的,某些平台(特别是 Windows)将提供分辨率大于 1 毫秒的计时器。
Bun
Bun 是一个专注性能与开发者体验的全新 JavaScript 运行时。它最近变得非常流行,仅去年(2022)第一个 Beta 版发布一个月内,就在 GitHub 上获得了超过两万的 star。
接下来我们来看看 Bun 中 setTimeout
的实现,其中关键代码在 src/bun.js/api/bun.zig 中,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
fn set(
id: i32,
globalThis: *JSGlobalObject,
callback: JSValue,
countdown: JSValue,
arguments_array_or_zero: JSValue,
repeat: bool,
) !void {
var vm = globalThis.bunVM();
// We don't deal with nesting levels directly
// but we do set the minimum timeout to be 1ms for repeating timers
const interval: i32 = @max(
countdown.coerce(i32, globalThis),
if (repeat) @as(i32, 1) else 0,
);
const kind: Timeout.Kind = if (repeat) .setInterval else .setTimeout;
var map = vm.timer.maps.get(kind);
// setImmediate(foo)
// setTimeout(foo, 0)
if (kind == .setTimeout and interval == 0) {
var cb: CallbackJob = .{
.callback = JSC.Strong.create(callback, globalThis),
.globalThis = globalThis,
.id = id,
.kind = kind,
};
var job = vm.allocator.create(CallbackJob) catch @panic(
"Out of memory while allocating Timeout",
);
job.* = cb;
job.task = CallbackJob.Task.init(job);
job.ref.ref(vm);
vm.enqueueTask(JSC.Task.init(&job.task));
map.put(vm.allocator, id, null) catch unreachable;
return;
}
...
}
|
可以看到 Bun 中对 setTimeout 为 0 的情况做了特殊处理;
如果定时器的类型为 .setTimeout
且时间间隔为 0,那么将会创建一个 CallbackJob
对象,这个对象会直接加入到任务队列中。否则会创建一个 Timeout 对象,然后将其加入到 Timeout 队列中。
这也就是为什么在上面 demo 中,setTimeout
为 0 的情况下,在 Bun 中的循环次数如此之高的原因,因为这个次数实际上就是事件循环的次数。
总结
看似非常常用的 setTimeout
函数,在不同的 JavaScript 运行时都有不同的实现,并且执行效果也不尽相同;
在浏览器中,setTimeout
大致符合 HTML5 标准,如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms
在 nodejs
中,如果设置的 timeout
为 0ms,则会被重置为 1ms,并且没有嵌套限制。
在 deno
中,也实现了类似 HTML5 标准 的行为,不过其底层是通过 Rust tokio
库实现的,该库的延时粒度取决于其执行的环境,某些平台将提供分辨率大于 1 毫秒的计时器。
在 Bun
中,如果设置的 timeout
为 0ms,则会被直接加入到任务队列中,所以 bun
中的循环次数会非常高。
参考文档