Web性能优化:timers

背景

众所周知,浏览器的 JavaScript 引擎是基于单线程的。我们可以通过 setTimeout 来创建异步任务,实现防抖(debounce)、节流(throttle)、缓存续期(renew)等操作。

通过 setTimeoutclearTimeout,我们很快就可以实现一个缓存管理器:

class Cache {
  // ...
  put(key, value, lifetime) {
    const cache = { value };
    this.data[key] = cache;
    this.hit(key, lifetime);
  }
  hit(key, lifetime) {
    const cache = this.data[key];
    if (cache) {
      clearTimeout(cache.timer);      cache.timer = setTimeout(this.clear, lifetime, key);    }
  }
}

然后在一个频繁使用缓存的场景下,我们惊奇地发现,缓存处理占用的时间占据了总处理时间的一半,时间都耗费在了 setTimeoutclearTimeout 上,这个开销是非常大的,甚至让我们使用缓存的意义都不那么大了。

vm cache

原因分析

首先,我们可以大概了解一下 setTimeoutclearTimeout 的原理。

根据HTML规范,可以知道,每次调用 setTimeout 都会发生以下步骤:

  • 做一些准备工作;
  • 生成一个 handle,值为一个大于 0 的整数,用于标识当前设置的 timeout;
  • 把这个 handle 加入到激活的定时器列表(list of active timers)中,并关联一个任务;
  • 返回这个 handle,等前面的 timer 执行完后执行这个任务。

每次 clearTimeout 都会从激活的定时器列表中去掉对应的 handle

这里就存在了一个问题,每次新增和销毁 timer,都会涉及一个列表的操作,而且由于不同的 timer 之间是有执行顺序的,所以这个列表中的元素必然会相互影响。所以不论这个列表底层是如何维护的,频繁操作肯定会有一些问题。

于是,我用 Chrome 的 Performance 工具做了个实验。

用上面实现的 cache,连续调用 1000 次缓存续期:

for (let i = 0; i < 1000; i += 1) {
  cache.hit(key, 1000);
}

得到如下结果:

cache many timers

从图中可以看到,CPU 的消耗有了一个高峰,setTimeoutclearTimeout 耗时近50ms,如果同时有别的操作,则很容易造成页面卡顿。而实际上,上面的代码啥都没干,从结果来看,把整个循环去掉,也不会产生什么区别,这就意味着,timer 的消耗非常不划算,有很大的优化空间。

解决方案

我们无法对浏览器底层进行优化,但是我们可以从实现上尽量避免产生无效的 timer:

  • 每次只产生一个 timer,同时保存续期后实际的到期时间;
  • 等当前 timer 到期后,重新计算超时时间,如果有需要,再重新创建一个 timer。

这个过程是连续的(consecutive),而不再是有很多个 timer 在并行,所以逻辑上更清晰了,效率也明显提升。

代码改造如下:

class Cache {
  // ...
  put(key, value, lifetime) {
    const cache = { value };
    this.data[key] = cache;
    this.hit(key, lifetime);
  }
  hit(key, lifetime) {
    const cache = this.data[key];
    if (cache) {
      cache.expire = Date.now() + lifetime;
      if (!cache.timer) this.checkLater(key);    }
  }
  check(key) {
    const cache = this.data[key];
    if (cache) {
      if (cache.expire < Date.now()) {
        delete this.data[key];
      } else {
        this.checkLater();
      }
    }
  }
  checkLater(key) {
    const cache = this.data[key];
    if (cache) {
      cache.timer = setTimeout(this.check, cache.expire - Date.now(), key);
    }
  }
}

改造后效果如下:

cache one timer

可以看到,CPU 几乎没有什么波动,总耗时只有1ms,差距非常明显。

延伸

除了缓存管理的场景,我们在处理频繁触发的事件(如 scrollmousemove)时,也会经常用到 timer 来减少处理的频率。这时 debounce 的实现就会存在和上面类似的潜在问题。如果在大型 SPA 中大量使用多 timer 并发的方式,就有可能导致页面性能大量损耗在 timer 上,而出现卡顿或者其他问题。

我们从监控上发现过少量奇怪的错误:

Uncaught RangeError: Failed to execute 'setTimeout' on 'Window': Too many PausableObjects

Google 了一下,并没有找到有效的信息,只是发现 PausableObject 是 Chromium 中实现的一个类,大概是用于 JavaScript 线程挂起的时候,暂停当前执行的任务,包括所有的 timer。更多信息见此

最后根据目前了解的情况和网上获得的极少信息,猜想这个问题出现的原因是,当前注册的 timer 已经多到影响每一帧的渲染或是达到了浏览器内部的某些限制了,所以无法再创建更多的 timer。所以就联想到可能跟 debounce 的实现方式上的缺陷有关。

总结

  • timer 的性能并不好,不适合大量使用;
  • 我们可以结合 JavaScript 是单线程的特点,从设计上避免大量使用 timer 的问题。

© 2020