Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

时间切片(Time Slicing) #38

Open
berwin opened this issue May 18, 2019 · 16 comments
Open

时间切片(Time Slicing) #38

berwin opened this issue May 18, 2019 · 16 comments
Assignees
Labels
performance Web performance

Comments

@berwin
Copy link
Owner

berwin commented May 18, 2019

时间切片(Time Slicing)

上周我在FDConf的分享《让你的网页更丝滑》中提到了“时间切片”,由于时间关系当时并没有对时间切片展开更细致的讨论。所以回来后就想着补一篇文章针对“时间切片”展开详细的讨论。

从用户的输入,再到显示器在视觉上给用户的输出,这一过程如果超过100ms,那么用户会察觉到网页的卡顿,所以为了解决这个问题,每个任务不能超过50ms,W3C性能工作组在LongTask规范中也将超过50ms的任务定义为长任务。

关于这50毫秒我在FDConf的分享中进行了很详细的讲解,没有听到的小伙伴也不用着急,后续我会针对这次分享的内容补一篇文章。

在线PPT地址:https://ppt.baomitu.com/d/b267a4a3

所以为了避免长任务,一种方案是使用Web Worker,将长任务放在Worker线程中执行,缺点是无法访问DOM,而另一种方案是使用时间切片。

什么是时间切片

时间切片的核心思想是:如果任务不能在50毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务。让出控制权意味着停止执行当前任务,让浏览器去执行其他任务,随后再回来继续执行没有执行完的任务。

所以时间切片的目的是不阻塞主线程,而实现目的的技术手段是将一个长任务拆分成很多个不超过50ms的小任务分散在宏任务队列中执行。

LongTask

上图可以看到主线程中有一个长任务,这个任务会阻塞主线程。使用时间切片将它切割成很多个小任务后,如下图所示。

task

可以看到现在的主线程有很多密密麻麻的小任务,我们将它放大后如下图所示。

task2

可以看到每个小任务中间是有空隙的,代表着任务执行了一小段时间后,将让出主线程的控制权,让浏览器执行其他的任务。

使用时间切片的缺点是,任务运行的总时间变长了,这是因为它每处理完一个小任务后,主线程会空闲出来,并且在下一个小任务开始处理之前有一小段延迟。

但是为了避免卡死浏览器,这种取舍是很有必要的。

如何使用时间切片

时间切片是一种概念,也可以理解为一种技术方案,它不是某个API的名字,也不是某个工具的名字。

事实上,时间切片充分利用了“异步”,在早期,可以使用定时器来实现,例如:

btn.onclick = function () {
  someThing(); // 执行了50毫秒
  setTimeout(function () {
    otherThing(); // 执行了50毫秒
  });
};

上面代码当按钮被点击时,本应执行100毫秒的任务现在被拆分成了两个50毫秒的任务。

在实际应用中,我们可以进行一些封装,封装后的使用效果类似下面这样:

btn.onclick = ts([someThing, otherThing], function () {
  console.log('done~');
});

当然,关于ts这个函数的API的设计并不是本文的重点,这里想说明的是,在早期可以利用定时器来实现“时间切片”。

ES6带来了迭代器的概念,并提供了生成器Generator函数用来生成迭代器对象,虽然Generator函数最正统的用法是生成迭代器对象,但这不妨我们利用它的特性做一些其他的事情。

Generator函数提供了yield关键字,这个关键字可以让函数暂停执行。然后通过迭代器对象的next方法让函数继续执行。

对Generator函数不熟悉的同学,需要先学习Generator函数的用法。

利用这个特性,我们可以设计出更方便使用的时间切片,例如:

btn.onclick = ts(function* () {
  someThing(); // 执行了50毫秒
  yield;
  otherThing(); // 执行了50毫秒
});

可以看到,我们只需要使用yield这个关键字就可以将本应执行100毫秒的任务拆分成了两个50毫秒的任务。

我们甚至可以将yield关键字放在循环里:

btn.onclick = ts(function* () {
  while (true) {
    someThing(); // 执行了50毫秒
    yield;
  }
});

上面代码我们写了一个死循环,但依然不会阻塞主线程,浏览器也不会卡死。

基于生成器的ts实现原理

通过前面的例子,我们会发现基于Generator的时间切片非常好用,但其实ts函数的实现原理非常简单,一个最简单的ts函数只需要九行代码

function ts (gen) {
  if (typeof gen === 'function') gen = gen()
  if (!gen || typeof gen.next !== 'function') return
  return function next() {
    const res = gen.next()
    if (res.done) return
    setTimeout(next)
  }
}

代码虽然全部只有9行,关键代码只有3、4行,但这几行代码充分利用了事件循环机制以及Generator函数的特性。

创造出这样的代码我还是很开心的。

上面代码核心思想是:通过yield关键字可以将任务暂停执行,从而让出主线程的控制权;通过定时器可以将“未完成的任务”重新放在任务队列中继续执行。

避免把任务分解的过于零碎

使用yield来切割任务非常方便,但如果切割的粒度特别细,反而效率不高。假设我们的任务执行100ms,最好的方式是切割成两个执行50ms的任务,而不是切割成100个执行1ms的任务。假设被切割的任务之间的间隔为4ms,那么切割成100个执行1ms的任务的总执行时间为:

(1 + 4) * 100 = 500ms

如果切割成两个执行时间为50ms的任务,那么总执行时间为:

(50 + 4) * 2 = 108ms

可以看到,在不影响用户体验的情况下,下面的总执行时间要比前面的少了4.6倍。

保证切割的任务刚好接近50ms,可以在用户使用yield时自行评估,也可以在ts函数中根据任务的执行时间判断是否应该一次性执行多个任务。

我们将ts函数稍微改进一下:

function ts (gen) {
  if (typeof gen === 'function') gen = gen()
  if (!gen || typeof gen.next !== 'function') return
  return function next() {
    const start = performance.now()
    let res = null
    do {
      res = gen.next()
    } while(!res.done && performance.now() - start < 25);

    if (res.done) return
    setTimeout(next)
  }
}

现在我们测试下:

ts(function* () {
  const start = performance.now()
  while (performance.now() - start < 1000) {
    console.log(11)
    yield
  }
  console.log('done!')
})();

这段代码在之前的版本中,在我的电脑上可以打印出 215 次 11,在后面的版本中可以打印出 6300 次 11,说明在总时间相同的情况下,可以执行更多的任务。

再看另一个例子:

ts(function* () {
  for (let i = 0; i < 10000; i++) {
    console.log(11)
    yield
  }
  console.log('done!')
})();

在我的电脑上,这段代码在之前的版本中,被切割成一万个小任务,总执行时间为 46秒,在之后的版本中,被切割成 52 个小任务,总执行时间为 1.5秒。

总结

我将时间切片的代码放在了我的Github上,感兴趣的可以参观下:https://github.com/berwin/time-slicing

@berwin berwin added the performance Web performance label May 18, 2019
@berwin berwin self-assigned this May 18, 2019
@berwin berwin changed the title 时间切片 时间切片(Time Slicing) May 18, 2019
@jiangtao
Copy link

有意思

@fangyinghua
Copy link

还可以这样用呀?

@GitHdu
Copy link

GitHdu commented Jul 22, 2019

可不可以理解为requestIdleCallback

@berwin
Copy link
Owner Author

berwin commented Jul 22, 2019

@GitHdu 不可以~ 不一样哒~😁

@Kuangchao-hzz
Copy link

是不是可以理解为,如果一个任务是主线程的,但是它的执行时间超过了50ms, 那就把它丢到异步去,让出主线程。

@berwin
Copy link
Owner Author

berwin commented Jul 23, 2019

@Kuangchao-hzz 也不是~~

@calvinchan22
Copy link

博文哥 这个测试的工具叫啥名字啊

@berwin
Copy link
Owner Author

berwin commented Aug 16, 2019

@calvinchan22 额?什么工具👀

@Vibing
Copy link

Vibing commented Nov 27, 2019

while(!res.done && performance.now() - start < 25);
这里为什么要设置 25 呢?

@berwin
Copy link
Owner Author

berwin commented Dec 4, 2019

@Vibing 保证每个任务最多执行25ms,每个同步执行的任务执行时间要控制在50ms内,再多就会阻塞主线程导致明显的卡顿。设置50ms我觉得还是不保险,就设置了25,属于经验值吧~

@xiaotiandada
Copy link

demo test
次:
// 1. 127
// 2. 2152


// 1. 22: 48561.037109375ms
// 2. 22: 1733.631103515625ms

这就是我和大佬💻配置的区别 枯了 逃(

@xiaotiandada
Copy link

@Vibing 保证每个任务最多执行25ms,每个同步执行的任务执行时间要控制在50ms内,再多就会阻塞主线程导致明显的卡顿。设置50ms我觉得还是不保险,就设置了25,属于经验值吧~

50 / 2 👍

@appGt
Copy link

appGt commented Jan 8, 2021

这个跟react的切片有什么不一样的地方?

@boyan1010
Copy link

这个时间切片,是协程的概念么?js中可以通过generator来创建协程

@zhizhiyaya
Copy link

难道只有我的浏览器被卡死了么 😂

@berwin
Copy link
Owner Author

berwin commented Jun 10, 2021

@zhizhiyaya 看起来是的 😂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
performance Web performance
Projects
None yet
Development

No branches or pull requests