JavaScript中定时器的精度

之前写了一篇文章介绍JS中的高精度计时,那么,与高精度相对的,低精度又是什么呢?或者说我们常接触到的精度是在什么水平?

这篇文章主要探讨一下JS里常用的定时器,看看它们能达到什么精度。由于结论我也不知道,所以基本上这篇文章算边做实验边写的吧,有问题希望各位看官能帮忙指出。文中的实验覆盖范围很小,而且方法也极度不严谨,大家先且一看吧,也许有时间我会再重新做实验。

正传
setTimeout
曾几何时,有前辈教诲我们,JS里setTimeout是不精确的,因为它所做的事情只是把任务添加到事件队列当中。如果在这个任务执行之前有别的任务执行的比较慢(比如死循环、大规模DOM操作、fs.同步IO等),那么后面的任务就会被推迟执行了。

与此同时,setTimeout(func, 0)是我们常见的一种奇怪的技巧,它可以让任务推迟执行,而又不推迟很多。说直观一点,通过这种技巧可以模拟一个低优先级的任务,比如我们在操作DOM的同时又希望window.scrollTo(0, 0),也许我们就会把后者放在setTimeout 0当中。在没有研究清楚event loop前,这也许是心理安慰,但因地制宜地用这个技巧常常会发生一些老中医般的意想不到的神奇效果。

我们先看看在没有任何其他繁忙任务时,setTimeout 0能达到多少精度。

1
2
3
4
5
var start = hrt();
setTimeout(function(){
  var now = hrt();
  console.log(now - start);
}, 0);

配合使用上回的高精度计时函数使用,在OSX Chrome34中,我这大概是9~10ms,而在node.js里则可以达到1.2~2.6ms的样子。然后我们慢慢增大延迟值,试着探索一下setTimeout有多少精度吧。
粗略实验下,发现在Chrome中,setTimeout的时间下限基本上就是9~10ms,当延迟在10多20这个水平时候,也能达到,但波动相当大。延迟到30以上,基本上实际时间比设置值只会多到1~2ms的样子;而在node中,即使设置很小的延迟,也能达到,但实际时间也会比设置值多个1~2ms。

模拟一下setTimeout被推迟的情况

1
2
3
4
5
6
var start = hrt();
for (var i=0; i<1e8; ++i) ;
setTimeout(function(){
  var now = hrt();
  console.log(now - start);
}, 0);

明显就看到时间变长多了,所以必须谨记setTimeout并不靠谱。

setInterval
这是用来做周期触发的回调用的,首先我们也丧心病狂的试试setInterval 0吧。

1
2
3
4
5
6
7
8
var start = hrt(), last = start;
var id = setInterval(function(){
  var now = hrt();
  console.log(now - last);
  last = now;

  if (now - start > 2000) clearInterval(id);
}, 0);

在Chrome里平均稳定在4.6ms左右一次,当时间设置到6ms以上时,基本上能达到,但实际触发时间比设置要大1ms左右。node这边依然要好一些,几乎能达到任何设置的时间,但也会有大概1ms的延迟。毫无疑问setInterval也是会被负荷重的任务推迟,就不演示了。

setImmediate
这是node.js才有的函数,我这里它大概有不到1ms的延迟。在朴灵的《深入浅出node.js》一书中对这个函数有比较详尽的解释,这里我就不赘述了。

不得不说的是——setImmediate也会被同步的代码阻塞——yes, this is JavaScript。

小结
到这里,常用的setXXX系列手工产生异步的办法都看了一遍。不得不承认node与浏览器在这些核心函数上优化都是相当到位的。但是其他浏览器,包括windows上,尤其是某些老旧的IE,我对它们表示不乐观,还好我现在的工作很少和这些东西打交道,改天有时间我应该会再用手机做一次测试,以求更贴近我的工作环境。

番外篇
requestAnimationFrame
这个东西,我觉得基本上把它当做一个setTimeout 0来看待就行了,现在比较推崇用它来做动画,我们也看看它的精度吧。

1
2
3
4
5
6
7
8
9
var start = hrt(), last = start;
function loop(){
  var now = hrt();
  console.log(now - last);
  last = now;

  if (now - start < 2000) requestAnimationFrame(loop);
}
loop();

我本来满心期望它有很稳定的触发间隔,但我失望了,从15ms多到17ms都有,不是很稳定。但据说它给浏览器带来的符合是更小的,所以会有更好的性能?这个15~17ms很有讲究,因为这刚好就是60FPS,似乎我还真没见过什么浏览器是超过60FPS的。

setZeroTimeout
这是一个很有趣的黑科技(github),它通过postMessage来为浏览器模拟setImmediate,(在可能的时候)避免使用setTimeout 0,试了一下,用它的确是能做到0~1ms的触发时间,简直厉害……

值得一提的是,我们有时候会用setTimeout嵌套来达到与setInterval类似的效果,嵌套使用setTimeout 0还可以(我刚试了下,反复setTimeout 0能达到和setInterval 0一样的4~5ms精度),嵌套setZeroTimeout因为触发太频繁,不出一秒浏览器就直接卡死了……

JavaScript中的高精度计时

HRT(High Resolution Timing, 高精度计时)在一些场合有很大的作用,比如游戏开发中,需要精确的计算两帧之间的时间差。

在JS中常常用(new Date()).getTime()来获取毫秒级的时间戳,虽然是毫秒级,但事实上它的真实精度只能达到大概16ms的级别。例如

1
2
3
while (true){
  console.log((new Date()).getTime()); // 这样死循环浏览器会跪的,责任自负
}

会发现它事实上大概16ms才跳一次,也许是17ms、又或者15ms吧,反正实际精度是有限的——什么?你跟我说是1ms?我告诉你那是因为新的系统或者浏览器使用了更高精度——但这不影响这篇文章的内容……

这对于日常应用来说完全够用了,但是对于游戏这样的场合,高精度计时就有它不可取代的意义了。

故事从这里开始
上面的获得毫秒级时间戳的方式之所以精度有限,是因为它的实现方式,以及它“绝对时间”的定义。
以Windows为例,这一类时间戳所使用的系统调用,比如GetSystemTime()MSDN、GetTickCount()MSDN,其函数的取值并不是实时的,而是通过硬件的时钟中断被动刷新的,这里的刷新间隔“正好”就是上面那个16ms。以GetSystemTime()为例,它返回的是SYSTEMTIME结构体,这用来进行时间日期处理的,因为时间日期处理通常根本不需要也不应该用那么高的精度(甚至很多时候只需要秒级别的精度),所以(new Date()).getTime()通过它们实现的确是可以胜任的。

现在我们明白了,靠这个时间戳是不能实现高精度计时的。
在Windows上,常常有两种高精度计时的方式:
第一种是timeGetTime()MSDN,它能返回系统启动到现在所经过的毫秒数,精度是1ms,因为它是32位的,所以大概49.71天会溢出清零。
第二种QueryPerformanceCounter()配合QueryPerformanceFrequency()MSDN实现,能够实现微秒级别的计时精度,对于大多数场合而言都够了。
当然还有更先进的方法,是通过CPU中的硬件计数器,和CPU每个时钟周期的时间,计算出更精确的时间(通常是纳秒级别的),对精度要求极高的场合这是最精确的选择了。

通常在使用固定位数的情况下,精度越高意味着计时的范围越小,这就不罗嗦了。

回到JavaScript中来
上面那些乱七八糟系统调用其实更咱们都没什么太大关系,我们能干什么完全看运行环境乐意给什么。
在webkit中提供了performance.now()参考文献来获取一个毫秒级的浮点数时间戳,我没查到资料它的有效精度是多少,不过既然给了个浮点数那就这么用着吧,我们就当它是微秒级的了!
在node.js中,有process.hrtime()DOC,返回的是一个数组[seconds, nanoseconds],看起来它具有纳秒级别的精度?且信了吧。

综合一下,我写了下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
exports.time = (function(){
  if (typeof window !== 'undefined'){
    // 浏览器
    if (typeof window.performance !== 'undefined' && typeof performance.now !== 'undefined'){
      // support hrt
      return function(){
        return performance.now();
      };
    }else{
      // oh no..
      return function(){
        return (new Date()).getTime();
      };
    }
  }else{
    // node.js
    return function(){
      var diff = process.hrtime();
      return (diff[0] * 1e9 + diff[1]) / 1e6; // nano second -> ms
    };
  }
})();

有了上面的代码(gist),我们就能写一个秒表神马的,在做性能测试的时候就用得上了。

最后还是要唠叨一句,HRT是用来计算时间差的,不是用来计算现实中时间(挂钟时间)的。

下一篇文章中,将会对JS中的时间精度进行进一步的讨论,对象自然就是setTimeout/setInterval了!

JS原生方法监听DOM结构改变事件

https://developer.mozilla.org/en-US/docs/XUL/Events#Mutation_DOM_events

1
2
3
document.addEventListener('DOMNodeInserted',function(){alert(1)},false);
document.addEventListener('DOMAttrModified',function(){alert(1)},false);
document.addEventListener('DOMNodeRemoved',function(){alert(1)},false);

变动事件包括以下不同事件类型:

1
2
3
4
5
6
7
DOMSubtreeModified; //在DOM结构中发生任何变化时触发
DOMNodeInserted; //在一个节点作为子节点被插入到另一个节点中时触发
DOMNodeRemoved; //在节点从其父节点中被移除时触发
DOMNodeRemovedFromDocument; //在一个节点被直接从文档中移除或通过子树间接从文档中移除之前触发
DOMNodeInsertedIntoDocument; //在一个节点被直接插入文档或通过子树间接插入文档之后触发
DOMAttrModified; //在属性被修改之后触发
DOMCharacterDataModified; //在文本节点的值发生变化时触发