setTimeout提前调用函数

5
我的库有基于实时的测试用例,我注意到测试会随机失败,并出现1毫秒误差。
expect(received).toBeGreaterThanOrEqual(expected)

    Expected: >= 1000
    Received:    999

这似乎是由于setTimeout过早调用函数引起的。
因此,我编写了一个单独的测试脚本:
let last = Date.now()

setTimeout(next, 1000)

function next() {
  if (Date.now() - last < 1000) process.exit(1)
  last = Date.now()
  setTimeout(next, 1000)
}

在Node.js v12.19.0、v14.15.3和v15.4.0上,它会随机失败:有时脚本可以继续运行,有时脚本很快就会退出。

这不仅发生在我的本地计算机上,也发生在Github的CI服务器上。

我的问题是:这是一个错误吗?还是setTimeout的某种预期行为?或者始终需要添加1毫秒?

更新:另请参见https://github.com/nodejs/node/issues/26578


1
setTimeOut参数的延迟从来不是一个时间函数,它仅告诉系统何时返回到您的函数。此延迟取决于其他处理器任务,并从未在特定时间发生。 - Mister Jojo
1
@MisterJojo 我知道,我理解为什么延迟1000可能会变成1000+,但是1000变成999...这真的正常吗? - BlackGlory
2
@MisterJojo 这让我很困惑,Node.js的文档似乎有所不同:“唯一的保证是超时不会比声明的超时间隔更早执行。”https://nodejs.org/en/docs/guides/timers-in-node/ - BlackGlory
如果你使用2毫秒进行测试,你可以在几秒钟内清楚地看到问题。我以为这是一些奇怪的Date.now()问题,但是process.hrtime()更精确地显示了完全相同的问题。 - niry
1
还有,请看这里,也许它会给你一些想法,为什么它会在你的系统上发生 https://github.com/nodejs/node/issues/26155 - Anastazy
显示剩余5条评论
2个回答

3

更新:在这里使用git-bisect发现了问题的罪魁祸首:

2c409a285359faae58227da283a4c7e5cd9a2f0c is the first bad commit
commit 2c409a285359faae58227da283a4c7e5cd9a2f0c
Date:   Tue Aug 25 13:36:37 2020 -0600

    perf_hooks: add idleTime and event loop util
    
    Use uv_metrics_idle_time() to return a high resolution millisecond timer
    of the amount of time the event loop has been idle since it was
    initialized.
    
    Include performance.eventLoopUtilization() API to handle the math of
    calculating the idle and active times. This has been added to prevent
    accidental miscalculations of the event loop utilization. Such as not
    taking into consideration offsetting nodeTiming.loopStart or timing
    differences when being called from a Worker thread.
    
    PR-URL: https://github.com/nodejs/node/pull/34938

这似乎是一个bug,而不是预期的行为。我反对总是添加1毫秒,因为行为不一致。(但是,它是否会比1毫秒更早?我没有观察到超过1毫秒)您可以通过以下方法解决问题:

const origSetTimeout = setTimeout;
setTimeout = (f, ms, ...args) => {
  let o;
  const when = Date.now() + ms,
    check = ()=> {
      let t = when - Date.now();
      if (t > 0) Object.assign(o, origSetTimeout(check, t));
      else f(...args);
    };
  return o = origSetTimeout(check, ms);
};

即使在解决问题时,也可以使用clearTimeout()

以下是一个浏览器代码,模拟该问题并每3秒钟交替使用解决方法:

// Simulate the problem
const realOrigSetTimeout = setTimeout;
setTimeout = (func, ms, ...args) => realOrigSetTimeout(func, ms - Math.random(), ...args);

const ms = 200;
let when = Date.now() + ms;
setTimeout(next, ms);

function next() {
  let now = Date.now();
  setTimeout(next, ms);
  console.log(now < when ? 'premature' : 'ok');
  when = now + ms;
}

function workAround() {
  console.log('Applying workaround');

  const origSetTimeout = setTimeout;
  setTimeout = (f, ms, ...args) => {
    let o;
    const when = Date.now() + ms,
      check = ()=> {
        let t = when - Date.now();
        if (t > 0) Object.assign(o, origSetTimeout(check, t));
        else f(...args);
      };
    return o = origSetTimeout(check, ms);
  };

  setTimeout(_=>{
    console.log('Removing workaround');
    setTimeout = origSetTimeout;
    setTimeout(workAround, 3000);
  }, 3000);
}
setTimeout(workAround, 3000);

以下是一个 Node.js 代码,它将清楚地显示问题('p' 在点之间),并在按下回车键后应用解决方法。
'use strict';

const ms = 1;
let when = Date.now() + ms;
setTimeout(next, ms);

function next() {
  let now = Date.now();
  setTimeout(next, ms);
  process.stdout.write(now < when ? 'p' : '.');
  when = now + ms;
}

process.stdin.on('readable', _=> {
  console.log('enabling workaround');
  const origSetTimeout = setTimeout;
  setTimeout = (f, ms, ...args) => {
    let o;
    const when = Date.now() + ms,
      check = ()=> {
        let t = when - Date.now();
        if (t > 0) Object.assign(o, origSetTimeout(check, t));
        else f(...args);
      };
    return o = origSetTimeout(check, ms);
  };
});

0
计时器(截至本文撰写时)在Node.js中以毫秒精度运行,并且一直以来都是如此,因此1000毫秒的计时器可能在只有999.95毫秒的情况下运行。由于Date.now()也是毫秒精度,所以同样的时间间隔可能只显示为999毫秒。
例如,假设一个计时器在纪元后的0.55毫秒开始,并在纪元后的1000.45毫秒运行。这是999.9毫秒的持续时间,毫秒精度四舍五入为1000毫秒,这就是计时器运行的原因。但是Date.now()是从纪元开始计算的。因此,就Date.now()而言,计时器是在纪元后的1毫秒设置的(从0.55毫秒四舍五入)并在纪元后的1000毫秒运行(从1000.45毫秒向下舍入),持续时间为999毫秒。
这是一个错误还是一个特殊情况,这是有争议的。(我认为这是一个错误,但理解那些持不同观点的人。)
我猜这只是为了通过测试,而不是真正可能影响用户的事情。如果是这样的话,你可以安全地将期望值更改为>= 999。另一个更麻烦的选择是使用process.hrtime()process.hrtime.bigint()来获取更高精度的时间戳,并相应地进行四舍五入。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接