在计算机里,并发「concurrent」一词,最早是用来表示多个任务同时进行。但是由于早期的计算机能力有限,单核计算机同一时间,只能运行一个任务。因此,为了做到看上去多个应用是在同时运行的,单核计算机就快速的在不同的应用中来回切换,它执行完 A 应用的一个任务,就执行 B 应用的任务,只要切换得足够快,对于用户而言,A 应用与 B 应用就是在同时运行。
因此,对于单核 CPU 来说,多个任务同时执行这种情况并不存在。
后来的主流计算机已经可以做到多个任务同时执行了,但是并发一词已经有了自己专属的场景,于是我们把真正的多个任务同时执行又重新取了一个名字,并行「parallel」
而并发则保留了它原本在单核 CPU 上的的含义:多个任务切换执行。为了知道下一个任务到底应该是谁执行了,那么单核 CPU 上必定会设计一个调度模式,用来确定任务的优先级。因此,并发的另外一个角度的解读,就是多个任务对同一执行资源的竞争。
一、React 的并发
在页面使用 JS 操作 DOM 渲染页面的过程中,也是同样的道理,他不存在有两个任务能同时执行的情况。不过,React 设计了一种机制,来模拟渲染资源的竞争。
首先,React 设计了一个调度器,Scheduler,来调度任务的优先级。
但是在争取谁更先渲染这个事情,在浏览器的渲染原理里,他经不起推敲。为什么呢?因为浏览器的底层渲染机制有收集逻辑,他会合并所有的渲染指令
div.style.color = 'red'
div.style.backgroundColor = '#FFF'
...
多个指令,会被合并成一个渲染任务。那也就意味着,对于浏览器而言,不存在渲染资源的竞争,因为不同的渲染指令都会被合并。既然这样,那 React 的并发又是怎么回事呢?
还有更诡异的事情,React 的渲染指令,是通过 setState
来触发,我们知道,多个 setState 指令,React 也会将他们合并批处理
setLoading(false)
setList([])
// 等价于
setState({
loading: false,
list: []
})
既然如此,并发体现在什么地方呢?也不存在渲染资源的竞争啊?我们看不到任务的切换执行,也看不到不同任务对渲染资源的竞争。所以真相就是...
大多数情况下,React 确实并不存在任何并发现象。
而事实上,当我们已经明确了哪些 DOM 需要被操作,对于浏览器来说,他可以足够快的渲染更新,因此,在一帧的时间里,就算合并非常多的 DOM 操作,浏览器也足以应对。够用,就表示竞争毫无意义。
只有在渲染超大量的 DOM 和大量表单时,浏览器的渲染引擎表示有压力
因此,资源竞争只会发生在,渲染能力不够用的时候。
一次渲染包括两个部分,一个部分是 JS 逻辑,我们需要在 JS 逻辑中明确具体的 DOM 操作是什么。第二个部分是渲染引擎执行渲染任务。很明显,对于 React 而言,他无法改变渲染引擎的逻辑。那么也就意味着,React 的并发只会发生在第一个部分:JS 逻辑中。
因此,react 还设计了第二步骤,Reconciler。当我们通过 setState 触发一个渲染任务时,react 需要在 Reconciler 中,利用 diff 算法找出来哪些 DOM 需要被更改。如果多个 setState 指令合并之后,我们发现 diff 过程超出了一帧的时间,这个时候就有可能会存在渲染资源的竞争。
Scheduler |
Reconciler |
Renderer |
收集 |
diff |
操作 DOM |
优先级 |
可中断 |
但是,如果只有一帧超出的时候,这一帧之后,浏览器再也没有新的渲染任务,那么就算超出了也无所谓。也没有必要去竞争渲染资源,只有一种可能,那就是短时间之内需要多次渲染。如果每一帧的时间都超标了,那么页面就会卡顿。
因此,只有在短时间之内页面需要多次渲染,才会存在资源竞争的情况。这个时候我们才会考虑并发的存在。
我们还需要进一步思考。刚才我们已经分析出,只有在短时间之内多次渲染,并且造成了页面卡顿,我们才会考虑并发。说明此时我们想要使用并发来解决的问题就是让页面不卡顿。因此,在多次渲染的前提下,多个任务的竞争结果就一定是渲染任务总量减少了,才会不卡顿。所以我们要做的事情就是,找出优先级更低的任务,即使他掉帧,只要不影响页面卡顿,我们都可以接受。
在 React 的底层设计中,setState 是一个任务,但是这个任务会影响哪些 UI 发生变化,它就可能会对应多个 Fiber,每一个 Fiber 的执行都是一个小任务,我们可以把一个任务看成一个函数。
一旦一个任务开始执行之后,React 不具备提前判断这个任务执行结束需要多少时间。只有等他执行完了,我们才能够算出来他一共执行了多久。因此,对于哪些 setState 是耗时较长的任务,React 无法判断,只有通过开发者自己去判断。我们需要在触发 setState
时,就标记这个任务的优先级,否则 react 也判断不了这个任务是否耗时比较长。因此,我们需要手动使用 startTransition
来标记耗时的 setState
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
// ……
}
另外一个问题就是,竞争是如何发生的。
通过时间切片中断任务的执行,给优先级更高的任务一个插队的机会。
例如上面例子,当我们使用 StartTransition 标记了 setTab 为一个耗时较长的任务时。setTab 会有许多小的 Fiber 节点任务组成,我们在 Reconciler 阶段执行每一个小的 Fiber 节点任务之前,都会判断此时是否应该打断循环。
function workLoop(hasTimeRemaining, initialTime) {
var currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (currentTask !== null && !(enableSchedulerDebugging )) {
if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())) {
// 当前任务尚未过期,但时间已经到了最后期限
break;
}
这里的 frameInterval 的具体值为 5ms,就是一个时间分片。也就是说,在 子 Fiber 任务执行的遍历过程中,每大于 5ms,就会被打断一次。这样才有给更高优先级任务执行的机会。
function shouldYieldToHost() {
var timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) { // 5ms
// 主线程只被阻塞了很短时间;
// smaller than a single frame. Don't yield yet.
return false;
}
// 主线程被阻塞的时间不可忽视
return true;
}
这里需要注意的是,setTab 最终被中断,是由于时间分片之内没有足够的时间给他执行每一个 Fiber 节点任务,而并非是由更高优先级的任务产生了导致它的中断。优先级只会影响队列的排序结果。
例如,假设 setTab 影响的 UI 中包含一个父级 Fiber 节点和 250 个子级Fiber 节点。如果我们对子 Fiber 节点增加一个 1ms 的阻塞,此时就至少有 50 个中断间隔给优先级更高的任务执行。
function Item(props: { text: string }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) {}
console.log('text')
return (
{props.text}
)
}
因此,在真实的渲染逻辑中,如果我的设备足够强悍,执行速度足够快,就算是我标记了低优先级,也可能不会被中断。
这里还需要注意的是,任务的最小单位是 Fiber,如果你的单个 Fiber 执行时间过长,react 也无法拆分这个任务。这种情况下,我们应该想办法把执行压力分散到子组件中去。
二、总结
到目前为止,React 的并发模式就只体现在任务优先级和任务可被中断上。如果单独考虑任务可被中断,他实现的效果就跟防抖、节流比较类似,概念比较高大上,但说穿了其实也没啥用。如果你不用 useTransition/useDefferedValue 的话,基本上你的任务也不会被中断。
但是如果不考虑任务可被中断呢,优先级队列其实也没啥太大的意义。所以 react 的并发模式,从我个人主观的角度来看的话,宣传意义大于实际意义。