從框架作者角度聊:React排程演算法的迭代過程

卡頌發表於2022-01-12

大家好,我卡頌。

React內部最難理解的地方就是排程演算法,不僅抽象、複雜,還重構了一次。

可以說,只有React團隊自己才能完全理解這套演算法。

既然這樣,那本文嘗試從React團隊成員的視角出發,來聊聊排程演算法

歡迎加入人類高質量前端框架群,帶飛

什麼是排程演算法

React在v16之前面對的主要效能問題是:當元件樹很龐大時,更新狀態可能造成頁面卡頓,根本原因在於:更新流程是同步、不可中斷的

為了解決這個問題,React提出Fiber架構,意在將更新流程變為非同步、可中斷的

最終實現的互動流程如下:

  1. 不同互動產生不同優先順序的更新(比如onClick回撥中的更新優先順序最高,useEffect回撥中觸發的更新優先順序一般)
  2. 排程演算法從眾多更新中選出一個優先順序作為本次render的優先順序
  3. 以步驟2選擇的優先順序對元件樹進行render

render過程中,如果又觸發互動流程,步驟2又選出一個更高優先順序,則之前的render中斷,以新的優先順序重新開始render

本文要聊的就是步驟2中的排程演算法

expirationTime排程演算法

排程演算法需要解決的最基本的問題是:如何從眾多更新中選擇其中一個更新的優先順序作為本次render的優先順序?

最早的演算法叫做expirationTime演算法

具體來說,更新的優先順序與觸發互動的當前時間優先順序對應的延遲時間相關:

// MAX_SIGNED_31_BIT_INT為最大31 bit Interger
update.expirationTime = MAX_SIGNED_31_BIT_INT - (currentTime + updatePriority);

例如,高優先順序更新u1、低優先順序更新u2的updatePriority分別為0、200,則

MAX_SIGNED_31_BIT_INT - (currentTime + 0) > MAX_SIGNED_31_BIT_INT - (currentTime + 200)

// 即
u1.expirationTime > u2.expirationTime;

代表u1優先順序更高。

expirationTime演算法的原理簡單易懂:每次都選出所有更新中優先順序最高的

如何表示“批次”

除此之外,還有個問題需要解決:如何表示批次

批次是什麼?考慮如下例子:

// 定義狀態num
const [num, updateNum] = useState(0);

// ...某些修改num的地方
// 修改的方式1
updateNum(3);
// 修改的方式2
updateNum(num => num + 1);

兩種修改狀態的方式都會建立更新,區別在於:

  • 第一種方式,不需考慮更新前的狀態,直接將狀態num修改為3
  • 第二種方式,需要基於更新前的狀態計算新狀態

由於第二種方式的存在,更新之間可能有連續性。

所以排程演算法計算出一個優先順序後,元件render時實際參與計算當前狀態的值的是:

計算出的優先順序對應更新 + 與該優先順序相關的其他優先順序對應更新

這些相互關聯,有連續性的更新被稱為一個批次batch)。

expirationTime演算法計算批次的方式也簡單粗暴:優先順序大於某個值(priorityOfBatch)的更新都會劃為同一批次。

const isUpdateIncludedInBatch = priorityOfUpdate >= priorityOfBatch;

expirationTime演算法保證了render非同步可中斷、且永遠是最高優先順序的更新先被處理。

這一時期該特性被稱為Async Mode

IO密集型場景

Async Mode可以解決以下問題:

  1. 元件樹邏輯複雜導致更新時卡頓(因為元件render變為可中斷
  2. 重要的互動更快響應(因為不同互動產生更新優先順序不同)

這些問題統稱為CPU密集型問題

在前端,還有一類問題也會影響體驗,那就是請求資料造成的等待。這類問題被稱為IO密集型問題

為了解決IO密集型問題的,React提出了Suspense。考慮如下程式碼:

const App = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const t = setInterval(() => {
      setCount(count => count + 1);
    }, 1000);
    return () => clearInterval(t);
  }, []);
  
  return (
    <>
      <Suspense fallback={<div>loading...</div>}>
        <Sub count={count} />
      </Suspense>
      <div>count is {count}</div>
    </>
  );
};

其中:

  • 每過一秒會觸發一次更新,將狀態count更新為count => count + 1
  • Sub中會發起非同步請求,請求返回前,包裹SubSuspense會渲染fallback

假設請求三秒後返回,理想情況下,請求發起前後UI會依次顯示為:

// Sub內請求發起前
<div class=“sub”>I am sub, count is 0</div>
<div>count is 0</div>

// Sub內請求發起第1秒
<div>loading...</div>
<div>count is 1</div>

// Sub內請求發起第2秒
<div>loading...</div>
<div>count is 2</div>

// Sub內請求發起第3秒
<div>loading...</div>
<div>count is 3</div>

// Sub內請求成功後
<div class=“sub”>I am sub, request success, count is 4</div>
<div>count is 4</div>

從使用者的視角觀察,有兩個任務在併發執行:

  1. 請求Sub的任務(觀察第一個div的變化)
  2. 改變count的任務(觀察第二個div的變化)

Suspense帶來了多工併發執行的直觀感受。

因此,Async Mode(非同步模式)也更名為Concurrent Mode(併發模式)。

一個無法解決的bug

那麼Suspense對應更新的優先順序是高還是低呢?

當請求成功後,合理的邏輯應該是儘快展示成功後的UI。所以Suspense對應更新應該是高優先順序更新。那麼,在示例中共有兩類更新:

  1. Suspense對應的高優IO更新,簡稱u0
  2. 每秒產生的低優CPU更新,簡稱u1u2u3

expirationTime演算法下:

// u0優先順序遠大於u1、u2、u3...
u0.expirationTime >> u1.expirationTime > u2.expirationTime > …

u0優先順序最高,則u1及之後的更新都需要等待u0執行完畢後再進行。

u0需要等待請求完畢才能執行。所以,請求發起前後UI會依次顯示為:

// Sub內請求發起前
<div class=“sub”>I am sub, count is 0</div>
<div>count is 0</div>

// Sub內請求發起第1秒
<div>loading...</div>
<div>count is 0</div>

// Sub內請求發起第2秒
<div>loading...</div>
<div>count is 0</div>

// Sub內請求發起第3秒
<div>loading...</div>
<div>count is 0</div>

// Sub內請求成功後
<div class=“sub”>I am sub, request success, count is 4</div>
<div>count is 4</div>

從使用者的視角觀察,第二個div被卡住了3秒後突然變為4。

所以,只考慮CPU密集型場景的情況下,高優更新先執行的演算法並無問題。

但考慮IO密集型場景的情況下,高優IO更新會阻塞低優CPU更新,這顯然是不對的。

所以expirationTime演算法並不能很好支援併發更新。

expirationTime演算法線上Demo

出現bug的原因

expirationTime演算法最大的問題在於:expirationTime欄位耦合了優先順序批次這兩個概念,限制了模型的表達能力。

這導致高優IO更新不會與低優CPU更新劃為同一批次。那麼低優CPU更新就必須等待高優IO更新處理完後再處理。

如果不同更新能根據實際情況靈活劃分批次,就不會產生這個bug

重構迫在眉睫,並且重構的目標很明確:將優先順序批次拆分到兩個欄位中。

Lane排程演算法

新的排程演算法被稱為Lane,他是如何定義優先順序批次呢?

對於優先順序,一個lane就是一個32bit Interger,最高位為符號位,所以最多可以有31個位參與運算。

不同優先順序對應不同lane,越低的位代表越高的優先順序,比如:

// 對應SyncLane,為最高優先順序
0b0000000000000000000000000000001
// 對應InputContinuousLane
0b0000000000000000000000000000100
// 對應DefaultLane
0b0000000000000000000000000010000
// 對應IdleLane
0b0100000000000000000000000000000
// 對應OffscreenLane,為最低優先順序
0b1000000000000000000000000000000

批次則由lanes定義,一個lanes同樣也是一個32bit Interger,代表一到多個lane的集合

可以用位運算很輕鬆的將多個lane劃入同一個批次

// 要使用的批次
let lanesForBatch = 0;

const laneA = 0b0000000000000000000000001000000;
const laneB = 0b0000000000000000000000000000001;

// 將laneA納入批次中
lanesForBatch |= laneA;
// 將laneB納入批次中
lanesForBatch |= laneB;

上文提到的Suspensebug是由於expirationTime演算法不能靈活劃定批次導致的。

lanes就完全沒有這種顧慮,任何想劃定為同一批次優先順序(lane)都能用位運算輕鬆搞定。

Lane演算法線上Demo

總結

排程演算法要解決兩個問題:

  1. 選取優先順序
  2. 選取批次

expirationTime演算法中使用的expirationTime欄位耦合了這兩個概念,導致不夠靈活。

Lane演算法的出現解決了以上問題。

相關文章