2

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

 1 year ago
source link: https://wangyulue.com/2023/03/%E4%BD%A0%E7%9C%9F%E7%9A%84%E4%BA%86%E8%A7%A3-settimeout-%E4%B9%88/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

wanger 2023-03-17  收录于 前端

在 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 时设置最小延时。参考下面这个例子:

// 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 浏览器中的输出为:

// [0, 0, 0, 0, 5, 10, 15, 20]
// 调用次数: 8

可以看到前 4 次调用的 timeout 都是 0ms,后面的间隔时间都超过了 4ms;

在一些其他的 JS 运行时中,例如 nodejsdenobun,其行为也不和 HTML5 标准中的规定一致。

不同运行时的 setTimeout 行为

nodejs:v16.14.0 中,上面例子的输出为:

// 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 中,上面例子的输出为:

// deno run test.js

// [
//   3, 4,  5,  6,
//   7, 8, 14, 20
// ]
// 调用次数: 8

deno 嵌套超过 5 层后有最小延时 4ms 的限制,但是前面的 4 次调用的 timeout 也都有 1ms 左右的延时;

bun:v0.5.7 中,上面例子的输出为:

// 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 ,关键代码如下:

// 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 中,关键代码如下:

// 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:v1.31.2 中,setTimeout 入口文件在 ext/node/polyfills/internal/timers.mjs, 在该文件中引用了 ext:deno_web/02_timers.js,实现延时限制关键代码在 initializeTimer 函数中,如下:

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 方法,该方法会调用 core.opAsync 方法,这会调用到 deno 中 Rust 的 op_sleep 函数,该函数具体位置在 ext/web/timers.rs 中:

#[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 是一个专注性能与开发者体验的全新 JavaScript 运行时。它最近变得非常流行,仅去年(2022)第一个 Beta 版发布一个月内,就在 GitHub 上获得了超过两万的 star。

接下来我们来看看 Bun 中 setTimeout 的实现,其中关键代码在 src/bun.js/api/bun.zig 中,如下:

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 中的循环次数会非常高。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK