目录

你真的了解 setTimeout 么?聊聊 setTimeout 的最小延时问题(附源码细节)

在 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 为 4mschrome 中的 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 运行时中,例如 nodejsdenobun,其行为也不和 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 中的循环次数会非常高。

参考文档