时间切片(Time Slicing)
从用户的输入,再到显示器在视觉上给用户的输出,这一过程如果超过 100ms,那么用户会察觉到网页的卡顿,所以为了解决这个问题,每个任务不能超过 50ms,W3C 性能工作组在 LongTask 规范中也将超过 50ms 的任务定义为长任务。
所以为了避免长任务,一种方案是使用 Web Worker,将长任务放在 Worker 线程中执行,缺点是无法访问 DOM,而另一种方案是使用时间切片。
什么是时间切片
时间切片的核心思想是:如果任务不能在 50 毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务。让出控制权意味着停止执行当前任务,让浏览器去执行其他任务,随后再回来继续执行没有执行完的任务。
所以时间切片的目的是不阻塞主线程,而实现目的的技术手段是将一个长任务拆分成很多个不超过 50ms 的小任务分散在宏任务队列中执行。
上图可以看到主线程中有一个长任务,这个任务会阻塞主线程。使用时间切片将它切割成很多个小任务后,如下图所示。
可以看到现在的主线程有很多密密麻麻的小任务,我们将它放大后如下图所示。
可以看到每个小任务中间是有空隙的,代表着任务执行了一小段时间后,将让出主线程的控制权,让浏览器执行其他的任务。
使用时间切片的缺点是,任务运行的总时间变长了,这是因为它每处理完一个小任务后,主线程会空闲出来,并且在下一个小任务开始处理之前有一小段延迟。
但是为了避免卡死浏览器,这种取舍是很有必要的。
如何使用时间切片
时间切片是一种概念,也可以理解为一种技术方案,它不是某个 API 的名字,也不是某个工具的名字。
事实上,时间切片充分利用了“异步”,在早期,可以使用定时器来实现,例如:
btn.onclick = function () {
someThing() // 执行了50毫秒
setTimeout(() => {
otherThing() // 执行了50毫秒
})
}
上面代码当按钮被点击时,本应执行 100 毫秒的任务现在被拆分成了两个 50 毫秒的任务。
在实际应用中,我们可以进行一些封装,封装后的使用效果类似下面这样:
btn.onclick = ts([someThing, otherThing], () => {
console.log('done~')
})
当然,关于 ts
这个函数的 API 的设计并不是本文的重点,这里想说明的是,在早期可以利用定时器来实现“时间切片”。
ES6 带来了迭代器的概念,并提供了生成器 Generator 函数用来生成迭代器对象,虽然 Generator 函数最正统的用法是生成迭代器对象,但这不妨我们利用它的特性做一些其他的事情。
Generator 函数提供了 yield
关键字,这个关键字可以让函数暂停执行。然后通过迭代器对象的 next
方法让函数继续执行。
利用这个特性,我们可以设计出更方便使用的时间切片,例如:
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 上,感兴趣的可以参观下:time-slicing
文章作者 Berwin,原文链接 时间切片(Time Slicing),转载请注明出处。