Reactのアーキテクチャを知る Scheduler編1

こんにちは、noyanです。React内部実装アドベントカレンダーの11日目です。

今日はSchedulerというパッケージのコードを読んでいきます。

なぜSchedulerについてここで学ぶのでしょうか。useState編を読まれた方は、Reactがhooksの更新検知と実行に関して不可知のまま議論が終わってしまったことに不満を抱かれているかもしれないと思っています。意図的にこの部分を無視したのは、レンダリング処理がかなり複雑で、新しい概念の羅列が人をoverwhelmすると思ったからです。新しい概念の名前だけ覚える形になるより、知的に面白く小さく始められる領域から進めていくほうが好ましいと考えるため、Schedulerという比較的小さなパッケージから読み進めましょう。

Schedulerは小さくても、Reactのなかでかなり重要なパッケージです。このパッケージはReactのアーキテクチャが難解になっている大きな要因です。このパッケージが片翼をになう優先度管理の概要を知ることでReactの構造がみえてくるでしょう。

Schedulerはなにをするのか

Reactはタスク処理の優先度を管理することで、インタラクティブな体験とパフォーマンスの両立を目指しています。シングルスレッドのプログラムで一般に、ある関数の実行中は別の関数を実行することはできません。ユーザーの入力に反応してレスポンスを返すプログラムは、ある関数が長時間実行されている間、ユーザーの入力に反応することができません。「終了ボタンをおしても終了しない??」という体験の裏には、シングルスレッドでは必ずしもユーザーの入力にすぐ反応できない制約が隠れています。

これを改善するには、「長時間たったら・ユーザーの入力があったら」自身の実行を中断するよう各関数が自律するようプログラムの仕方を変える必要があります。Schedulerはこの機能を担っています。

Reactのインタラクティブ性改善については、以前記事を書きました。こちらも参考にしてください。

脱線:テストコードリーディング

ReactチームはSchedulerは将来Reactから独立してパッケージ化することを検討していた(いる)ため、React特有の文脈から機能を類推することができません。加えてドキュメントも整備されていません。そのため前提知識なくコードを読むことになります。テストコードは、こういう時の文脈補完に役立ちます。

たとえば以下のテスト2つを観察すると、前者ではTaskが連続して処理される一方で、後者ではruntime.advanceTime(4999)が挿入されると、Taskの間にPost Message,Message Eventがおかれたことがわかります。

// packages/scheduler/src/__tests__/Scheduler-test.js
  it('multiple tasks', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('A');
    });
    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'A', 'B']);
  });

  it('multiple tasks with a yield in between', () => {
    scheduleCallback(NormalPriority, () => {
      runtime.log('A');
      runtime.advanceTime(4999);
    });
    scheduleCallback(NormalPriority, () => {
      runtime.log('B');
    });
    runtime.assertLog(['Post Message']);
    runtime.fireMessageEvent();
    runtime.assertLog([
      'Message Event',
      'A',
      // Ran out of time. Post a continuation event.
      'Post Message',
    ]);
    runtime.fireMessageEvent();
    runtime.assertLog(['Message Event', 'B']);
  });

これを他の知識と組み合わせると以下のことがわかります。

  • PostMessageを使ってschedulerを起動する
  • 一定時間を超えるとschedulerが終了する

このように、テストコードは振る舞いについて雄弁です。コードリーディング一般に、全体像の把握で困ったときは読んでみてください。

workLoop

Schedulerの役割は2つあります。

  • 現在のタスクを優先度順で実行する
  • 未来のタスクの管理

workLoop関数はこれら2つの根幹を担っているため、ここからコードを読み始めましょう。

なお、「現在のタスク」と「未来のタスク」のキューはそれぞれtaskQueue, timerQueueで管理されています。これらは優先度付きキューというデータ型を用いて優先度の高い順にデータを取り出せるようになっています。

peek(taskQueue): 最優先のタスクを参照

pop(taskQueue): 最優先のタスクを取得し、taskQueueから除外

現在のタスクを優先度順で実行する

workLoop関数の前半が担当しています。workLoop関数は優先順位が高い順にtaskのcallback関数を実行していきます。Reactの場合はこのコールバックにレンダリング命令が渡されていて、コールバックが実行されるたびにReactのレンダリングが走ります。

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  currentTask = peek(taskQueue);

  // currentTaskを処理し続け、taskQueueがなくなったら終了する
  while (currentTask !== null) {
    // schedulerをとめる?
    if (shouldYield) {
      break;
    }
    const callback = currentTask.callback;
    const continuationCallback = callback(didUserCallbackTimeout);

    // 現在のtaskを継続する?
    if (typeof continuationCallback === "function") {
      currentTask.callback = continuationCallback;
    } else {
      // 継続しなくてよい。currentTaskを更新する
      if (currentTask === peek(taskQueue)) {
        pop(taskQueue);
      }
      currentTask = peek(taskQueue);
    }
  }
  // 後半
}

将来の実行予約

前半部分のwhile文を抜けたので、taskQueueが空になったか、shouldYieldがtrueになりました。もしtaskQueueが空になった(現在やるべきtaskがない)なら、schedulerを一旦終了します。そうでなければtaskをすぐ再開できるようにします。

timerQueueは今実行する必要がないタスク群なので、schedulerの開始タイミングを指定します。

function workLoop(hasTimeRemaining, initialTime) {
  // 上記省略
    
  // 次呼ばれるタイミングの予約
  // ブラウザから戻ったらすぐ
  if (currentTask !== null) {
    return true;
  } else {
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      // 開始タイミングを指定
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

今回は次の事項について省略しました。

  • shouldYieldToHostで実行をとめる
  • 開始時間を迎えたときtimerQueueからtaskQueueへ移動

次はどのように優先度を取り出しているかについて扱いたいと思います。